refactor(tmux-subagent): split manager and decision-engine into focused modules
Extract session lifecycle, polling, grid planning, and event handling: - polling.ts: session polling controller with stability detection - event-handlers.ts: session created/deleted handlers - grid-planning.ts, spawn-action-decider.ts, spawn-target-finder.ts - session-status-parser.ts, session-message-count.ts - cleanup.ts, polling-constants.ts, tmux-grid-constants.ts
This commit is contained in:
42
src/features/tmux-subagent/cleanup.ts
Normal file
42
src/features/tmux-subagent/cleanup.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import { log } from "../../shared"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { executeAction } from "./action-executor"
|
||||
|
||||
export async function cleanupTmuxSessions(params: {
|
||||
tmuxConfig: TmuxConfig
|
||||
serverUrl: string
|
||||
sourcePaneId: string | undefined
|
||||
sessions: Map<string, TrackedSession>
|
||||
stopPolling: () => void
|
||||
}): Promise<void> {
|
||||
params.stopPolling()
|
||||
|
||||
if (params.sessions.size === 0) {
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] closing all panes", { count: params.sessions.size })
|
||||
const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null
|
||||
|
||||
if (state) {
|
||||
const closePromises = Array.from(params.sessions.values()).map((tracked) =>
|
||||
executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
|
||||
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
|
||||
).catch((error) =>
|
||||
log("[tmux-session-manager] cleanup error for pane", {
|
||||
paneId: tracked.paneId,
|
||||
error: String(error),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all(closePromises)
|
||||
}
|
||||
|
||||
params.sessions.clear()
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
}
|
||||
@@ -1,386 +1,22 @@
|
||||
import type { WindowState, PaneAction, SpawnDecision, CapacityConfig, TmuxPaneInfo, SplitDirection } from "./types"
|
||||
import { MIN_PANE_WIDTH, MIN_PANE_HEIGHT } from "./types"
|
||||
export type { SessionMapping } from "./oldest-agent-pane"
|
||||
export type { GridCapacity, GridPlan, GridSlot } from "./grid-planning"
|
||||
export type { SpawnTarget } from "./spawn-target-finder"
|
||||
|
||||
export interface SessionMapping {
|
||||
sessionId: string
|
||||
paneId: string
|
||||
createdAt: Date
|
||||
}
|
||||
export {
|
||||
calculateCapacity,
|
||||
computeGridPlan,
|
||||
mapPaneToSlot,
|
||||
} from "./grid-planning"
|
||||
|
||||
export interface GridCapacity {
|
||||
cols: number
|
||||
rows: number
|
||||
total: number
|
||||
}
|
||||
export {
|
||||
canSplitPane,
|
||||
canSplitPaneAnyDirection,
|
||||
findMinimalEvictions,
|
||||
getBestSplitDirection,
|
||||
getColumnCount,
|
||||
getColumnWidth,
|
||||
isSplittableAtCount,
|
||||
} from "./pane-split-availability"
|
||||
|
||||
export interface GridSlot {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export interface GridPlan {
|
||||
cols: number
|
||||
rows: number
|
||||
slotWidth: number
|
||||
slotHeight: number
|
||||
}
|
||||
|
||||
export interface SpawnTarget {
|
||||
targetPaneId: string
|
||||
splitDirection: SplitDirection
|
||||
}
|
||||
|
||||
const MAIN_PANE_RATIO = 0.5
|
||||
const MAX_COLS = 2
|
||||
const MAX_ROWS = 3
|
||||
const MAX_GRID_SIZE = 4
|
||||
const DIVIDER_SIZE = 1
|
||||
const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
||||
const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
||||
|
||||
export function getColumnCount(paneCount: number): number {
|
||||
if (paneCount <= 0) return 1
|
||||
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
|
||||
}
|
||||
|
||||
export function getColumnWidth(agentAreaWidth: number, paneCount: number): number {
|
||||
const cols = getColumnCount(paneCount)
|
||||
const dividersWidth = (cols - 1) * DIVIDER_SIZE
|
||||
return Math.floor((agentAreaWidth - dividersWidth) / cols)
|
||||
}
|
||||
|
||||
export function isSplittableAtCount(agentAreaWidth: number, paneCount: number): boolean {
|
||||
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
||||
return columnWidth >= MIN_SPLIT_WIDTH
|
||||
}
|
||||
|
||||
export function findMinimalEvictions(agentAreaWidth: number, currentCount: number): number | null {
|
||||
for (let k = 1; k <= currentCount; k++) {
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
|
||||
if (direction === "-h") {
|
||||
return pane.width >= MIN_SPLIT_WIDTH
|
||||
}
|
||||
return pane.height >= MIN_SPLIT_HEIGHT
|
||||
}
|
||||
|
||||
export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean {
|
||||
return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT
|
||||
}
|
||||
|
||||
export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null {
|
||||
const canH = pane.width >= MIN_SPLIT_WIDTH
|
||||
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
||||
|
||||
if (!canH && !canV) return null
|
||||
if (canH && !canV) return "-h"
|
||||
if (!canH && canV) return "-v"
|
||||
return pane.width >= pane.height ? "-h" : "-v"
|
||||
}
|
||||
|
||||
export function calculateCapacity(
|
||||
windowWidth: number,
|
||||
windowHeight: number
|
||||
): GridCapacity {
|
||||
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const cols = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE))))
|
||||
const rows = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE))))
|
||||
const total = cols * rows
|
||||
return { cols, rows, total }
|
||||
}
|
||||
|
||||
export function computeGridPlan(
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
paneCount: number
|
||||
): GridPlan {
|
||||
const capacity = calculateCapacity(windowWidth, windowHeight)
|
||||
const { cols: maxCols, rows: maxRows } = capacity
|
||||
|
||||
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
||||
return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 }
|
||||
}
|
||||
|
||||
let bestCols = 1
|
||||
let bestRows = 1
|
||||
let bestArea = Infinity
|
||||
|
||||
for (let rows = 1; rows <= maxRows; rows++) {
|
||||
for (let cols = 1; cols <= maxCols; cols++) {
|
||||
if (cols * rows >= paneCount) {
|
||||
const area = cols * rows
|
||||
if (area < bestArea || (area === bestArea && rows < bestRows)) {
|
||||
bestCols = cols
|
||||
bestRows = rows
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const slotWidth = Math.floor(availableWidth / bestCols)
|
||||
const slotHeight = Math.floor(windowHeight / bestRows)
|
||||
|
||||
return { cols: bestCols, rows: bestRows, slotWidth, slotHeight }
|
||||
}
|
||||
|
||||
export function mapPaneToSlot(
|
||||
pane: TmuxPaneInfo,
|
||||
plan: GridPlan,
|
||||
mainPaneWidth: number
|
||||
): GridSlot {
|
||||
const rightAreaX = mainPaneWidth
|
||||
const relativeX = Math.max(0, pane.left - rightAreaX)
|
||||
const relativeY = pane.top
|
||||
|
||||
const col = plan.slotWidth > 0
|
||||
? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth))
|
||||
: 0
|
||||
const row = plan.slotHeight > 0
|
||||
? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight))
|
||||
: 0
|
||||
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
function buildOccupancy(
|
||||
agentPanes: TmuxPaneInfo[],
|
||||
plan: GridPlan,
|
||||
mainPaneWidth: number
|
||||
): Map<string, TmuxPaneInfo> {
|
||||
const occupancy = new Map<string, TmuxPaneInfo>()
|
||||
for (const pane of agentPanes) {
|
||||
const slot = mapPaneToSlot(pane, plan, mainPaneWidth)
|
||||
const key = `${slot.row}:${slot.col}`
|
||||
occupancy.set(key, pane)
|
||||
}
|
||||
return occupancy
|
||||
}
|
||||
|
||||
function findFirstEmptySlot(
|
||||
occupancy: Map<string, TmuxPaneInfo>,
|
||||
plan: GridPlan
|
||||
): GridSlot {
|
||||
for (let row = 0; row < plan.rows; row++) {
|
||||
for (let col = 0; col < plan.cols; col++) {
|
||||
const key = `${row}:${col}`
|
||||
if (!occupancy.has(key)) {
|
||||
return { row, col }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row: plan.rows - 1, col: plan.cols - 1 }
|
||||
}
|
||||
|
||||
function findSplittableTarget(
|
||||
state: WindowState,
|
||||
preferredDirection?: SplitDirection
|
||||
): SpawnTarget | null {
|
||||
if (!state.mainPane) return null
|
||||
|
||||
const existingCount = state.agentPanes.length
|
||||
|
||||
if (existingCount === 0) {
|
||||
const virtualMainPane: TmuxPaneInfo = {
|
||||
...state.mainPane,
|
||||
width: state.windowWidth,
|
||||
}
|
||||
if (canSplitPane(virtualMainPane, "-h")) {
|
||||
return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1)
|
||||
const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO)
|
||||
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
||||
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
||||
|
||||
const leftKey = `${targetSlot.row}:${targetSlot.col - 1}`
|
||||
const leftPane = occupancy.get(leftKey)
|
||||
if (leftPane && canSplitPane(leftPane, "-h")) {
|
||||
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
||||
}
|
||||
|
||||
const aboveKey = `${targetSlot.row - 1}:${targetSlot.col}`
|
||||
const abovePane = occupancy.get(aboveKey)
|
||||
if (abovePane && canSplitPane(abovePane, "-v")) {
|
||||
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
||||
}
|
||||
|
||||
const splittablePanes = state.agentPanes
|
||||
.map(p => ({ pane: p, direction: getBestSplitDirection(p) }))
|
||||
.filter(({ direction }) => direction !== null)
|
||||
.sort((a, b) => (b.pane.width * b.pane.height) - (a.pane.width * a.pane.height))
|
||||
|
||||
if (splittablePanes.length > 0) {
|
||||
const best = splittablePanes[0]
|
||||
return { targetPaneId: best.pane.paneId, splitDirection: best.direction! }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findSpawnTarget(state: WindowState): SpawnTarget | null {
|
||||
return findSplittableTarget(state)
|
||||
}
|
||||
|
||||
function findOldestSession(mappings: SessionMapping[]): SessionMapping | null {
|
||||
if (mappings.length === 0) return null
|
||||
return mappings.reduce((oldest, current) =>
|
||||
current.createdAt < oldest.createdAt ? current : oldest
|
||||
)
|
||||
}
|
||||
|
||||
function findOldestAgentPane(
|
||||
agentPanes: TmuxPaneInfo[],
|
||||
sessionMappings: SessionMapping[]
|
||||
): TmuxPaneInfo | null {
|
||||
if (agentPanes.length === 0) return null
|
||||
|
||||
const paneIdToAge = new Map<string, Date>()
|
||||
for (const mapping of sessionMappings) {
|
||||
paneIdToAge.set(mapping.paneId, mapping.createdAt)
|
||||
}
|
||||
|
||||
const panesWithAge = agentPanes
|
||||
.map(p => ({ pane: p, age: paneIdToAge.get(p.paneId) }))
|
||||
.filter(({ age }) => age !== undefined)
|
||||
.sort((a, b) => a.age!.getTime() - b.age!.getTime())
|
||||
|
||||
if (panesWithAge.length > 0) {
|
||||
return panesWithAge[0].pane
|
||||
}
|
||||
|
||||
return agentPanes.reduce((oldest, p) => {
|
||||
if (p.top < oldest.top || (p.top === oldest.top && p.left < oldest.left)) {
|
||||
return p
|
||||
}
|
||||
return oldest
|
||||
})
|
||||
}
|
||||
|
||||
export function decideSpawnActions(
|
||||
state: WindowState,
|
||||
sessionId: string,
|
||||
description: string,
|
||||
_config: CapacityConfig,
|
||||
sessionMappings: SessionMapping[]
|
||||
): SpawnDecision {
|
||||
if (!state.mainPane) {
|
||||
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||
}
|
||||
|
||||
const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const currentCount = state.agentPanes.length
|
||||
|
||||
if (agentAreaWidth < MIN_PANE_WIDTH) {
|
||||
return {
|
||||
canSpawn: false,
|
||||
actions: [],
|
||||
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
|
||||
}
|
||||
}
|
||||
|
||||
const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings)
|
||||
const oldestMapping = oldestPane
|
||||
? sessionMappings.find(m => m.paneId === oldestPane.paneId)
|
||||
: null
|
||||
|
||||
if (currentCount === 0) {
|
||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||
if (canSplitPane(virtualMainPane, "-h")) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: state.mainPane.paneId,
|
||||
splitDirection: "-h"
|
||||
}]
|
||||
}
|
||||
}
|
||||
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
||||
}
|
||||
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
|
||||
const spawnTarget = findSplittableTarget(state)
|
||||
if (spawnTarget) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: spawnTarget.targetPaneId,
|
||||
splitDirection: spawnTarget.splitDirection
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
|
||||
|
||||
if (minEvictions === 1 && oldestPane) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
{
|
||||
type: "close",
|
||||
paneId: oldestPane.paneId,
|
||||
sessionId: oldestMapping?.sessionId || ""
|
||||
},
|
||||
{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: state.mainPane.paneId,
|
||||
splitDirection: "-h"
|
||||
}
|
||||
],
|
||||
reason: "closed 1 pane to make room for split"
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestPane) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [{
|
||||
type: "replace",
|
||||
paneId: oldestPane.paneId,
|
||||
oldSessionId: oldestMapping?.sessionId || "",
|
||||
newSessionId: sessionId,
|
||||
description
|
||||
}],
|
||||
reason: "replaced oldest pane (no split possible)"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canSpawn: false,
|
||||
actions: [],
|
||||
reason: "no pane available to replace"
|
||||
}
|
||||
}
|
||||
|
||||
export function decideCloseAction(
|
||||
state: WindowState,
|
||||
sessionId: string,
|
||||
sessionMappings: SessionMapping[]
|
||||
): PaneAction | null {
|
||||
const mapping = sessionMappings.find((m) => m.sessionId === sessionId)
|
||||
if (!mapping) return null
|
||||
|
||||
const paneExists = state.agentPanes.some((p) => p.paneId === mapping.paneId)
|
||||
if (!paneExists) return null
|
||||
|
||||
return { type: "close", paneId: mapping.paneId, sessionId }
|
||||
}
|
||||
export { findSpawnTarget } from "./spawn-target-finder"
|
||||
export { decideCloseAction, decideSpawnActions } from "./spawn-action-decider"
|
||||
|
||||
6
src/features/tmux-subagent/event-handlers.ts
Normal file
6
src/features/tmux-subagent/event-handlers.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { coerceSessionCreatedEvent } from "./session-created-event"
|
||||
export type { SessionCreatedEvent } from "./session-created-event"
|
||||
export { handleSessionCreated } from "./session-created-handler"
|
||||
export type { SessionCreatedHandlerDeps } from "./session-created-handler"
|
||||
export { handleSessionDeleted } from "./session-deleted-handler"
|
||||
export type { SessionDeletedHandlerDeps } from "./session-deleted-handler"
|
||||
107
src/features/tmux-subagent/grid-planning.ts
Normal file
107
src/features/tmux-subagent/grid-planning.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
MAIN_PANE_RATIO,
|
||||
MAX_GRID_SIZE,
|
||||
} from "./tmux-grid-constants"
|
||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||
|
||||
export interface GridCapacity {
|
||||
cols: number
|
||||
rows: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GridSlot {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export interface GridPlan {
|
||||
cols: number
|
||||
rows: number
|
||||
slotWidth: number
|
||||
slotHeight: number
|
||||
}
|
||||
|
||||
export function calculateCapacity(
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
): GridCapacity {
|
||||
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const cols = Math.min(
|
||||
MAX_GRID_SIZE,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE),
|
||||
),
|
||||
),
|
||||
)
|
||||
const rows = Math.min(
|
||||
MAX_GRID_SIZE,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE),
|
||||
),
|
||||
),
|
||||
)
|
||||
return { cols, rows, total: cols * rows }
|
||||
}
|
||||
|
||||
export function computeGridPlan(
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
paneCount: number,
|
||||
): GridPlan {
|
||||
const capacity = calculateCapacity(windowWidth, windowHeight)
|
||||
const { cols: maxCols, rows: maxRows } = capacity
|
||||
|
||||
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
||||
return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 }
|
||||
}
|
||||
|
||||
let bestCols = 1
|
||||
let bestRows = 1
|
||||
let bestArea = Infinity
|
||||
|
||||
for (let rows = 1; rows <= maxRows; rows++) {
|
||||
for (let cols = 1; cols <= maxCols; cols++) {
|
||||
if (cols * rows < paneCount) continue
|
||||
const area = cols * rows
|
||||
if (area < bestArea || (area === bestArea && rows < bestRows)) {
|
||||
bestCols = cols
|
||||
bestRows = rows
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const slotWidth = Math.floor(availableWidth / bestCols)
|
||||
const slotHeight = Math.floor(windowHeight / bestRows)
|
||||
|
||||
return { cols: bestCols, rows: bestRows, slotWidth, slotHeight }
|
||||
}
|
||||
|
||||
export function mapPaneToSlot(
|
||||
pane: TmuxPaneInfo,
|
||||
plan: GridPlan,
|
||||
mainPaneWidth: number,
|
||||
): GridSlot {
|
||||
const rightAreaX = mainPaneWidth
|
||||
const relativeX = Math.max(0, pane.left - rightAreaX)
|
||||
const relativeY = pane.top
|
||||
|
||||
const col =
|
||||
plan.slotWidth > 0
|
||||
? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth))
|
||||
: 0
|
||||
const row =
|
||||
plan.slotHeight > 0
|
||||
? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight))
|
||||
: 0
|
||||
|
||||
return { row, col }
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
export * from "./manager"
|
||||
export * from "./event-handlers"
|
||||
export * from "./polling"
|
||||
export * from "./cleanup"
|
||||
export * from "./session-created-event"
|
||||
export * from "./session-created-handler"
|
||||
export * from "./session-deleted-handler"
|
||||
export * from "./polling-constants"
|
||||
export * from "./session-status-parser"
|
||||
export * from "./session-message-count"
|
||||
export * from "./session-ready-waiter"
|
||||
export * from "./types"
|
||||
export * from "./pane-state-querier"
|
||||
export * from "./decision-engine"
|
||||
|
||||
@@ -4,23 +4,20 @@ import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import {
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS,
|
||||
SESSION_READY_TIMEOUT_MS,
|
||||
} from "../../shared/tmux"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||
import { executeActions, executeAction } from "./action-executor"
|
||||
import type { SessionMapping } from "./decision-engine"
|
||||
import {
|
||||
coerceSessionCreatedEvent,
|
||||
handleSessionCreated,
|
||||
handleSessionDeleted,
|
||||
type SessionCreatedEvent,
|
||||
} from "./event-handlers"
|
||||
import { createSessionPollingController, type SessionPollingController } from "./polling"
|
||||
import { cleanupTmuxSessions } from "./cleanup"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SessionCreatedEvent {
|
||||
type: string
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
@@ -31,13 +28,6 @@ const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
}
|
||||
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
// Stability detection constants (prevents premature closure - see issue #1330)
|
||||
// Mirrors the proven pattern from background-agent/manager.ts
|
||||
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
|
||||
const STABLE_POLLS_REQUIRED = 3 // 3 consecutive idle polls (~6s with 2s poll interval)
|
||||
|
||||
/**
|
||||
* State-first Tmux Session Manager
|
||||
*
|
||||
@@ -57,8 +47,8 @@ export class TmuxSessionManager {
|
||||
private sourcePaneId: string | undefined
|
||||
private sessions = new Map<string, TrackedSession>()
|
||||
private pendingSessions = new Set<string>()
|
||||
private pollInterval?: ReturnType<typeof setInterval>
|
||||
private deps: TmuxUtilDeps
|
||||
private polling: SessionPollingController
|
||||
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||
this.client = ctx.client
|
||||
@@ -68,6 +58,14 @@ export class TmuxSessionManager {
|
||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
|
||||
this.polling = createSessionPollingController({
|
||||
client: this.client,
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
sourcePaneId: this.sourcePaneId,
|
||||
sessions: this.sessions,
|
||||
})
|
||||
|
||||
log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
@@ -95,378 +93,58 @@ export class TmuxSessionManager {
|
||||
}))
|
||||
}
|
||||
|
||||
private async waitForSessionReady(sessionId: string): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
|
||||
try {
|
||||
const statusResult = await this.client.session.status({ path: undefined })
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
if (allStatuses[sessionId]) {
|
||||
log("[tmux-session-manager] session ready", {
|
||||
sessionId,
|
||||
status: allStatuses[sessionId].type,
|
||||
waitedMs: Date.now() - startTime,
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
log("[tmux-session-manager] session status check error", { error: String(err) })
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] session ready timeout", {
|
||||
sessionId,
|
||||
timeoutMs: SESSION_READY_TIMEOUT_MS,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||
const enabled = this.isEnabled()
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
})
|
||||
|
||||
if (!enabled) return
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
|
||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.sourcePaneId) {
|
||||
log("[tmux-session-manager] no source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingSessions.add(sessionId)
|
||||
|
||||
try {
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state")
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] window state queried", {
|
||||
windowWidth: state.windowWidth,
|
||||
mainPane: state.mainPane?.paneId,
|
||||
agentPaneCount: state.agentPanes.length,
|
||||
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||
})
|
||||
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
sessionId,
|
||||
title,
|
||||
this.getCapacityConfig(),
|
||||
this.getSessionMappings()
|
||||
)
|
||||
|
||||
log("[tmux-session-manager] spawn decision", {
|
||||
canSpawn: decision.canSpawn,
|
||||
reason: decision.reason,
|
||||
actionCount: decision.actions.length,
|
||||
actions: decision.actions.map((a) => {
|
||||
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||
return { type: "spawn", sessionId: a.sessionId }
|
||||
}),
|
||||
})
|
||||
|
||||
if (!decision.canSpawn) {
|
||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await executeActions(
|
||||
decision.actions,
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
|
||||
for (const { action, result: actionResult } of result.results) {
|
||||
if (action.type === "close" && actionResult.success) {
|
||||
this.sessions.delete(action.sessionId)
|
||||
log("[tmux-session-manager] removed closed session from cache", {
|
||||
sessionId: action.sessionId,
|
||||
})
|
||||
}
|
||||
if (action.type === "replace" && actionResult.success) {
|
||||
this.sessions.delete(action.oldSessionId)
|
||||
log("[tmux-session-manager] removed replaced session from cache", {
|
||||
oldSessionId: action.oldSessionId,
|
||||
newSessionId: action.newSessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success && result.spawnedPaneId) {
|
||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||
|
||||
if (!sessionReady) {
|
||||
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
this.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
description: title,
|
||||
createdAt: new Date(now),
|
||||
lastSeenAt: new Date(now),
|
||||
})
|
||||
log("[tmux-session-manager] pane spawned and tracked", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
sessionReady,
|
||||
})
|
||||
this.startPolling()
|
||||
} else {
|
||||
log("[tmux-session-manager] spawn failed", {
|
||||
success: result.success,
|
||||
results: result.results.map((r) => ({
|
||||
type: r.action.type,
|
||||
success: r.result.success,
|
||||
error: r.result.error,
|
||||
})),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.pendingSessions.delete(sessionId)
|
||||
}
|
||||
await handleSessionCreated(
|
||||
{
|
||||
client: this.client,
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
sourcePaneId: this.sourcePaneId,
|
||||
sessions: this.sessions,
|
||||
pendingSessions: this.pendingSessions,
|
||||
isInsideTmux: this.deps.isInsideTmux,
|
||||
isEnabled: () => this.isEnabled(),
|
||||
getCapacityConfig: () => this.getCapacityConfig(),
|
||||
getSessionMappings: () => this.getSessionMappings(),
|
||||
waitForSessionReady: (sessionId) => this.polling.waitForSessionReady(sessionId),
|
||||
startPolling: () => this.polling.startPolling(),
|
||||
},
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (!this.sourcePaneId) return
|
||||
|
||||
const tracked = this.sessions.get(event.sessionID)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
this.sessions.delete(event.sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
||||
if (closeAction) {
|
||||
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
|
||||
}
|
||||
|
||||
this.sessions.delete(event.sessionID)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollInterval) return
|
||||
|
||||
this.pollInterval = setInterval(
|
||||
() => this.pollSessions(),
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
await handleSessionDeleted(
|
||||
{
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
sourcePaneId: this.sourcePaneId,
|
||||
sessions: this.sessions,
|
||||
isEnabled: () => this.isEnabled(),
|
||||
getSessionMappings: () => this.getSessionMappings(),
|
||||
stopPolling: () => this.polling.stopPolling(),
|
||||
},
|
||||
event,
|
||||
)
|
||||
log("[tmux-session-manager] polling started")
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval)
|
||||
this.pollInterval = undefined
|
||||
log("[tmux-session-manager] polling stopped")
|
||||
}
|
||||
}
|
||||
|
||||
private async pollSessions(): Promise<void> {
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await this.client.session.status({ path: undefined })
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
log("[tmux-session-manager] pollSessions", {
|
||||
trackedSessions: Array.from(this.sessions.keys()),
|
||||
allStatusKeys: Object.keys(allStatuses),
|
||||
})
|
||||
|
||||
const now = Date.now()
|
||||
const sessionsToClose: string[] = []
|
||||
|
||||
for (const [sessionId, tracked] of this.sessions.entries()) {
|
||||
const status = allStatuses[sessionId]
|
||||
const isIdle = status?.type === "idle"
|
||||
|
||||
if (status) {
|
||||
tracked.lastSeenAt = new Date(now)
|
||||
}
|
||||
|
||||
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
||||
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
||||
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
|
||||
const elapsedMs = now - tracked.createdAt.getTime()
|
||||
|
||||
// Stability detection: Don't close immediately on idle
|
||||
// Wait for STABLE_POLLS_REQUIRED consecutive polls with same message count
|
||||
let shouldCloseViaStability = false
|
||||
|
||||
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||
// Fetch message count to detect if agent is still producing output
|
||||
try {
|
||||
const messagesResult = await this.client.session.messages({
|
||||
path: { id: sessionId }
|
||||
})
|
||||
const currentMsgCount = Array.isArray(messagesResult.data)
|
||||
? messagesResult.data.length
|
||||
: 0
|
||||
|
||||
if (tracked.lastMessageCount === currentMsgCount) {
|
||||
// Message count unchanged - increment stable polls
|
||||
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
|
||||
|
||||
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
|
||||
// Double-check status before closing
|
||||
const recheckResult = await this.client.session.status({ path: undefined })
|
||||
const recheckStatuses = (recheckResult.data ?? {}) as Record<string, { type: string }>
|
||||
const recheckStatus = recheckStatuses[sessionId]
|
||||
|
||||
if (recheckStatus?.type === "idle") {
|
||||
shouldCloseViaStability = true
|
||||
} else {
|
||||
// Status changed - reset stability counter
|
||||
tracked.stableIdlePolls = 0
|
||||
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
|
||||
sessionId,
|
||||
recheckStatus: recheckStatus?.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New messages - agent is still working, reset stability counter
|
||||
tracked.stableIdlePolls = 0
|
||||
}
|
||||
|
||||
tracked.lastMessageCount = currentMsgCount
|
||||
} catch (msgErr) {
|
||||
log("[tmux-session-manager] failed to fetch messages for stability check", {
|
||||
sessionId,
|
||||
error: String(msgErr),
|
||||
})
|
||||
// On error, don't close - be conservative
|
||||
}
|
||||
} else if (!isIdle) {
|
||||
// Not idle - reset stability counter
|
||||
tracked.stableIdlePolls = 0
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] session check", {
|
||||
sessionId,
|
||||
statusType: status?.type,
|
||||
isIdle,
|
||||
elapsedMs,
|
||||
stableIdlePolls: tracked.stableIdlePolls,
|
||||
lastMessageCount: tracked.lastMessageCount,
|
||||
missingSince,
|
||||
missingTooLong,
|
||||
isTimedOut,
|
||||
shouldCloseViaStability,
|
||||
})
|
||||
|
||||
// Close if: stability detection confirmed OR missing too long OR timed out
|
||||
// Note: We no longer close immediately on idle - stability detection handles that
|
||||
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
|
||||
sessionsToClose.push(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of sessionsToClose) {
|
||||
log("[tmux-session-manager] closing session due to poll", { sessionId })
|
||||
await this.closeSessionById(sessionId)
|
||||
}
|
||||
} catch (err) {
|
||||
log("[tmux-session-manager] poll error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
private async closeSessionById(sessionId: string): Promise<void> {
|
||||
const tracked = this.sessions.get(sessionId)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] closing session pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
if (state) {
|
||||
await executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
return async (input) => {
|
||||
await this.onSessionCreated(input.event as SessionCreatedEvent)
|
||||
await this.onSessionCreated(coerceSessionCreatedEvent(input.event))
|
||||
}
|
||||
}
|
||||
|
||||
async pollSessions(): Promise<void> {
|
||||
return this.polling.pollSessions()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.stopPolling()
|
||||
|
||||
if (this.sessions.size > 0) {
|
||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
|
||||
if (state) {
|
||||
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
||||
executeAction(
|
||||
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
).catch((err) =>
|
||||
log("[tmux-session-manager] cleanup error for pane", {
|
||||
paneId: s.paneId,
|
||||
error: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Promise.all(closePromises)
|
||||
}
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
await cleanupTmuxSessions({
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
sourcePaneId: this.sourcePaneId,
|
||||
sessions: this.sessions,
|
||||
stopPolling: () => this.polling.stopPolling(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
37
src/features/tmux-subagent/oldest-agent-pane.ts
Normal file
37
src/features/tmux-subagent/oldest-agent-pane.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { TmuxPaneInfo } from "./types"
|
||||
|
||||
export interface SessionMapping {
|
||||
sessionId: string
|
||||
paneId: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export function findOldestAgentPane(
|
||||
agentPanes: TmuxPaneInfo[],
|
||||
sessionMappings: SessionMapping[],
|
||||
): TmuxPaneInfo | null {
|
||||
if (agentPanes.length === 0) return null
|
||||
|
||||
const paneIdToAge = new Map<string, Date>()
|
||||
for (const mapping of sessionMappings) {
|
||||
paneIdToAge.set(mapping.paneId, mapping.createdAt)
|
||||
}
|
||||
|
||||
const panesWithAge = agentPanes
|
||||
.map((pane) => ({ pane, age: paneIdToAge.get(pane.paneId) }))
|
||||
.filter(
|
||||
(item): item is { pane: TmuxPaneInfo; age: Date } => item.age !== undefined,
|
||||
)
|
||||
.sort((a, b) => a.age.getTime() - b.age.getTime())
|
||||
|
||||
if (panesWithAge.length > 0) {
|
||||
return panesWithAge[0].pane
|
||||
}
|
||||
|
||||
return agentPanes.reduce((oldest, pane) => {
|
||||
if (pane.top < oldest.top || (pane.top === oldest.top && pane.left < oldest.left)) {
|
||||
return pane
|
||||
}
|
||||
return oldest
|
||||
})
|
||||
}
|
||||
60
src/features/tmux-subagent/pane-split-availability.ts
Normal file
60
src/features/tmux-subagent/pane-split-availability.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
MAX_COLS,
|
||||
MAX_ROWS,
|
||||
MIN_SPLIT_HEIGHT,
|
||||
MIN_SPLIT_WIDTH,
|
||||
} from "./tmux-grid-constants"
|
||||
|
||||
export function getColumnCount(paneCount: number): number {
|
||||
if (paneCount <= 0) return 1
|
||||
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
|
||||
}
|
||||
|
||||
export function getColumnWidth(agentAreaWidth: number, paneCount: number): number {
|
||||
const cols = getColumnCount(paneCount)
|
||||
const dividersWidth = (cols - 1) * DIVIDER_SIZE
|
||||
return Math.floor((agentAreaWidth - dividersWidth) / cols)
|
||||
}
|
||||
|
||||
export function isSplittableAtCount(
|
||||
agentAreaWidth: number,
|
||||
paneCount: number,
|
||||
): boolean {
|
||||
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
||||
return columnWidth >= MIN_SPLIT_WIDTH
|
||||
}
|
||||
|
||||
export function findMinimalEvictions(
|
||||
agentAreaWidth: number,
|
||||
currentCount: number,
|
||||
): number | null {
|
||||
for (let k = 1; k <= currentCount; k++) {
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount - k)) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
|
||||
if (direction === "-h") {
|
||||
return pane.width >= MIN_SPLIT_WIDTH
|
||||
}
|
||||
return pane.height >= MIN_SPLIT_HEIGHT
|
||||
}
|
||||
|
||||
export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean {
|
||||
return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT
|
||||
}
|
||||
|
||||
export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null {
|
||||
const canH = pane.width >= MIN_SPLIT_WIDTH
|
||||
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
||||
|
||||
if (!canH && !canV) return null
|
||||
if (canH && !canV) return "-h"
|
||||
if (!canH && canV) return "-v"
|
||||
return pane.width >= pane.height ? "-h" : "-v"
|
||||
}
|
||||
6
src/features/tmux-subagent/polling-constants.ts
Normal file
6
src/features/tmux-subagent/polling-constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
// Stability detection constants (prevents premature closure - see issue #1330)
|
||||
// Mirrors the proven pattern from background-agent/manager.ts
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const STABLE_POLLS_REQUIRED = 3
|
||||
183
src/features/tmux-subagent/polling.ts
Normal file
183
src/features/tmux-subagent/polling.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import {
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
} from "../../shared/tmux"
|
||||
import { log } from "../../shared"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { executeAction } from "./action-executor"
|
||||
import {
|
||||
MIN_STABILITY_TIME_MS,
|
||||
SESSION_TIMEOUT_MS,
|
||||
STABLE_POLLS_REQUIRED,
|
||||
} from "./polling-constants"
|
||||
import { parseSessionStatusMap } from "./session-status-parser"
|
||||
import { getMessageCount } from "./session-message-count"
|
||||
import { waitForSessionReady as waitForSessionReadyFromClient } from "./session-ready-waiter"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export interface SessionPollingController {
|
||||
startPolling: () => void
|
||||
stopPolling: () => void
|
||||
closeSessionById: (sessionId: string) => Promise<void>
|
||||
waitForSessionReady: (sessionId: string) => Promise<boolean>
|
||||
pollSessions: () => Promise<void>
|
||||
}
|
||||
|
||||
export function createSessionPollingController(params: {
|
||||
client: OpencodeClient
|
||||
tmuxConfig: TmuxConfig
|
||||
serverUrl: string
|
||||
sourcePaneId: string | undefined
|
||||
sessions: Map<string, TrackedSession>
|
||||
}): SessionPollingController {
|
||||
let pollInterval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
async function closeSessionById(sessionId: string): Promise<void> {
|
||||
const tracked = params.sessions.get(sessionId)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] closing session pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
|
||||
const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null
|
||||
if (state) {
|
||||
await executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
|
||||
)
|
||||
}
|
||||
|
||||
params.sessions.delete(sessionId)
|
||||
|
||||
if (params.sessions.size === 0) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSessions(): Promise<void> {
|
||||
if (params.sessions.size === 0) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResult = await params.client.session.status({ path: undefined })
|
||||
const allStatuses = parseSessionStatusMap(statusResult.data)
|
||||
|
||||
log("[tmux-session-manager] pollSessions", {
|
||||
trackedSessions: Array.from(params.sessions.keys()),
|
||||
allStatusKeys: Object.keys(allStatuses),
|
||||
})
|
||||
|
||||
const now = Date.now()
|
||||
const sessionsToClose: string[] = []
|
||||
|
||||
for (const [sessionId, tracked] of params.sessions.entries()) {
|
||||
const status = allStatuses[sessionId]
|
||||
const isIdle = status?.type === "idle"
|
||||
|
||||
if (status) {
|
||||
tracked.lastSeenAt = new Date(now)
|
||||
}
|
||||
|
||||
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
||||
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
||||
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
|
||||
const elapsedMs = now - tracked.createdAt.getTime()
|
||||
|
||||
let shouldCloseViaStability = false
|
||||
|
||||
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||
try {
|
||||
const messagesResult = await params.client.session.messages({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
const currentMessageCount = getMessageCount(messagesResult.data)
|
||||
|
||||
if (tracked.lastMessageCount === currentMessageCount) {
|
||||
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
|
||||
|
||||
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
|
||||
const recheckResult = await params.client.session.status({ path: undefined })
|
||||
const recheckStatuses = parseSessionStatusMap(recheckResult.data)
|
||||
const recheckStatus = recheckStatuses[sessionId]
|
||||
|
||||
if (recheckStatus?.type === "idle") {
|
||||
shouldCloseViaStability = true
|
||||
} else {
|
||||
tracked.stableIdlePolls = 0
|
||||
log(
|
||||
"[tmux-session-manager] stability reached but session not idle on recheck, resetting",
|
||||
{ sessionId, recheckStatus: recheckStatus?.type },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracked.stableIdlePolls = 0
|
||||
}
|
||||
|
||||
tracked.lastMessageCount = currentMessageCount
|
||||
} catch (messageError) {
|
||||
log("[tmux-session-manager] failed to fetch messages for stability check", {
|
||||
sessionId,
|
||||
error: String(messageError),
|
||||
})
|
||||
}
|
||||
} else if (!isIdle) {
|
||||
tracked.stableIdlePolls = 0
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] session check", {
|
||||
sessionId,
|
||||
statusType: status?.type,
|
||||
isIdle,
|
||||
elapsedMs,
|
||||
stableIdlePolls: tracked.stableIdlePolls,
|
||||
lastMessageCount: tracked.lastMessageCount,
|
||||
missingSince,
|
||||
missingTooLong,
|
||||
isTimedOut,
|
||||
shouldCloseViaStability,
|
||||
})
|
||||
|
||||
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
|
||||
sessionsToClose.push(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of sessionsToClose) {
|
||||
log("[tmux-session-manager] closing session due to poll", { sessionId })
|
||||
await closeSessionById(sessionId)
|
||||
}
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] poll error", { error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (pollInterval) return
|
||||
pollInterval = setInterval(() => {
|
||||
void pollSessions()
|
||||
}, POLL_INTERVAL_BACKGROUND_MS)
|
||||
log("[tmux-session-manager] polling started")
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (!pollInterval) return
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = undefined
|
||||
log("[tmux-session-manager] polling stopped")
|
||||
}
|
||||
|
||||
async function waitForSessionReady(sessionId: string): Promise<boolean> {
|
||||
return waitForSessionReadyFromClient({ client: params.client, sessionId })
|
||||
}
|
||||
|
||||
return { startPolling, stopPolling, closeSessionById, waitForSessionReady, pollSessions }
|
||||
}
|
||||
44
src/features/tmux-subagent/session-created-event.ts
Normal file
44
src/features/tmux-subagent/session-created-event.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getNestedRecord(value: unknown, key: string): UnknownRecord | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
const nested = value[key]
|
||||
return isRecord(nested) ? nested : undefined
|
||||
}
|
||||
|
||||
function getNestedString(value: unknown, key: string): string | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
const nested = value[key]
|
||||
return typeof nested === "string" ? nested : undefined
|
||||
}
|
||||
|
||||
export interface SessionCreatedEvent {
|
||||
type: string
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export function coerceSessionCreatedEvent(input: {
|
||||
type: string
|
||||
properties?: unknown
|
||||
}): SessionCreatedEvent {
|
||||
const properties = isRecord(input.properties) ? input.properties : undefined
|
||||
const info = getNestedRecord(properties, "info")
|
||||
|
||||
return {
|
||||
type: input.type,
|
||||
properties:
|
||||
info || properties
|
||||
? {
|
||||
info: {
|
||||
id: getNestedString(info, "id"),
|
||||
parentID: getNestedString(info, "parentID"),
|
||||
title: getNestedString(info, "title"),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
163
src/features/tmux-subagent/session-created-handler.ts
Normal file
163
src/features/tmux-subagent/session-created-handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { CapacityConfig, TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||
import { executeActions } from "./action-executor"
|
||||
import type { SessionCreatedEvent } from "./session-created-event"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export interface SessionCreatedHandlerDeps {
|
||||
client: OpencodeClient
|
||||
tmuxConfig: TmuxConfig
|
||||
serverUrl: string
|
||||
sourcePaneId: string | undefined
|
||||
sessions: Map<string, TrackedSession>
|
||||
pendingSessions: Set<string>
|
||||
isInsideTmux: () => boolean
|
||||
isEnabled: () => boolean
|
||||
getCapacityConfig: () => CapacityConfig
|
||||
getSessionMappings: () => SessionMapping[]
|
||||
waitForSessionReady: (sessionId: string) => Promise<boolean>
|
||||
startPolling: () => void
|
||||
}
|
||||
|
||||
export async function handleSessionCreated(
|
||||
deps: SessionCreatedHandlerDeps,
|
||||
event: SessionCreatedEvent,
|
||||
): Promise<void> {
|
||||
const enabled = deps.isEnabled()
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: deps.tmuxConfig.enabled,
|
||||
isInsideTmux: deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
})
|
||||
|
||||
if (!enabled) return
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!deps.sourcePaneId) {
|
||||
log("[tmux-session-manager] no source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
deps.pendingSessions.add(sessionId)
|
||||
|
||||
try {
|
||||
const state = await queryWindowState(deps.sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state")
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] window state queried", {
|
||||
windowWidth: state.windowWidth,
|
||||
mainPane: state.mainPane?.paneId,
|
||||
agentPaneCount: state.agentPanes.length,
|
||||
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||
})
|
||||
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
sessionId,
|
||||
title,
|
||||
deps.getCapacityConfig(),
|
||||
deps.getSessionMappings(),
|
||||
)
|
||||
|
||||
log("[tmux-session-manager] spawn decision", {
|
||||
canSpawn: decision.canSpawn,
|
||||
reason: decision.reason,
|
||||
actionCount: decision.actions.length,
|
||||
actions: decision.actions.map((a) => {
|
||||
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||
if (a.type === "replace") {
|
||||
return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||
}
|
||||
return { type: "spawn", sessionId: a.sessionId }
|
||||
}),
|
||||
})
|
||||
|
||||
if (!decision.canSpawn) {
|
||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await executeActions(decision.actions, {
|
||||
config: deps.tmuxConfig,
|
||||
serverUrl: deps.serverUrl,
|
||||
windowState: state,
|
||||
})
|
||||
|
||||
for (const { action, result: actionResult } of result.results) {
|
||||
if (action.type === "close" && actionResult.success) {
|
||||
deps.sessions.delete(action.sessionId)
|
||||
log("[tmux-session-manager] removed closed session from cache", {
|
||||
sessionId: action.sessionId,
|
||||
})
|
||||
}
|
||||
if (action.type === "replace" && actionResult.success) {
|
||||
deps.sessions.delete(action.oldSessionId)
|
||||
log("[tmux-session-manager] removed replaced session from cache", {
|
||||
oldSessionId: action.oldSessionId,
|
||||
newSessionId: action.newSessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success || !result.spawnedPaneId) {
|
||||
log("[tmux-session-manager] spawn failed", {
|
||||
success: result.success,
|
||||
results: result.results.map((r) => ({
|
||||
type: r.action.type,
|
||||
success: r.result.success,
|
||||
error: r.result.error,
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sessionReady = await deps.waitForSessionReady(sessionId)
|
||||
if (!sessionReady) {
|
||||
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
deps.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
description: title,
|
||||
createdAt: new Date(now),
|
||||
lastSeenAt: new Date(now),
|
||||
})
|
||||
|
||||
log("[tmux-session-manager] pane spawned and tracked", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
sessionReady,
|
||||
})
|
||||
|
||||
deps.startPolling()
|
||||
} finally {
|
||||
deps.pendingSessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
50
src/features/tmux-subagent/session-deleted-handler.ts
Normal file
50
src/features/tmux-subagent/session-deleted-handler.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||
import { executeAction } from "./action-executor"
|
||||
|
||||
export interface SessionDeletedHandlerDeps {
|
||||
tmuxConfig: TmuxConfig
|
||||
serverUrl: string
|
||||
sourcePaneId: string | undefined
|
||||
sessions: Map<string, TrackedSession>
|
||||
isEnabled: () => boolean
|
||||
getSessionMappings: () => SessionMapping[]
|
||||
stopPolling: () => void
|
||||
}
|
||||
|
||||
export async function handleSessionDeleted(
|
||||
deps: SessionDeletedHandlerDeps,
|
||||
event: { sessionID: string },
|
||||
): Promise<void> {
|
||||
if (!deps.isEnabled()) return
|
||||
if (!deps.sourcePaneId) return
|
||||
|
||||
const tracked = deps.sessions.get(event.sessionID)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
|
||||
const state = await queryWindowState(deps.sourcePaneId)
|
||||
if (!state) {
|
||||
deps.sessions.delete(event.sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const closeAction = decideCloseAction(state, event.sessionID, deps.getSessionMappings())
|
||||
if (closeAction) {
|
||||
await executeAction(closeAction, {
|
||||
config: deps.tmuxConfig,
|
||||
serverUrl: deps.serverUrl,
|
||||
windowState: state,
|
||||
})
|
||||
}
|
||||
|
||||
deps.sessions.delete(event.sessionID)
|
||||
|
||||
if (deps.sessions.size === 0) {
|
||||
deps.stopPolling()
|
||||
}
|
||||
}
|
||||
3
src/features/tmux-subagent/session-message-count.ts
Normal file
3
src/features/tmux-subagent/session-message-count.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getMessageCount(data: unknown): number {
|
||||
return Array.isArray(data) ? data.length : 0
|
||||
}
|
||||
44
src/features/tmux-subagent/session-ready-waiter.ts
Normal file
44
src/features/tmux-subagent/session-ready-waiter.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
SESSION_READY_POLL_INTERVAL_MS,
|
||||
SESSION_READY_TIMEOUT_MS,
|
||||
} from "../../shared/tmux"
|
||||
import { log } from "../../shared"
|
||||
import { parseSessionStatusMap } from "./session-status-parser"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export async function waitForSessionReady(params: {
|
||||
client: OpencodeClient
|
||||
sessionId: string
|
||||
}): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
|
||||
try {
|
||||
const statusResult = await params.client.session.status({ path: undefined })
|
||||
const allStatuses = parseSessionStatusMap(statusResult.data)
|
||||
|
||||
if (allStatuses[params.sessionId]) {
|
||||
log("[tmux-session-manager] session ready", {
|
||||
sessionId: params.sessionId,
|
||||
status: allStatuses[params.sessionId].type,
|
||||
waitedMs: Date.now() - startTime,
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] session status check error", { error: String(error) })
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS)
|
||||
})
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] session ready timeout", {
|
||||
sessionId: params.sessionId,
|
||||
timeoutMs: SESSION_READY_TIMEOUT_MS,
|
||||
})
|
||||
return false
|
||||
}
|
||||
17
src/features/tmux-subagent/session-status-parser.ts
Normal file
17
src/features/tmux-subagent/session-status-parser.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
type SessionStatus = { type: string }
|
||||
|
||||
export function parseSessionStatusMap(data: unknown): Record<string, SessionStatus> {
|
||||
if (typeof data !== "object" || data === null) return {}
|
||||
const record = data as Record<string, unknown>
|
||||
|
||||
const result: Record<string, SessionStatus> = {}
|
||||
for (const [sessionId, value] of Object.entries(record)) {
|
||||
if (typeof value !== "object" || value === null) continue
|
||||
const valueRecord = value as Record<string, unknown>
|
||||
const type = valueRecord["type"]
|
||||
if (typeof type !== "string") continue
|
||||
result[sessionId] = { type }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
135
src/features/tmux-subagent/spawn-action-decider.ts
Normal file
135
src/features/tmux-subagent/spawn-action-decider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
CapacityConfig,
|
||||
PaneAction,
|
||||
SpawnDecision,
|
||||
TmuxPaneInfo,
|
||||
WindowState,
|
||||
} from "./types"
|
||||
import { MAIN_PANE_RATIO } from "./tmux-grid-constants"
|
||||
import {
|
||||
canSplitPane,
|
||||
findMinimalEvictions,
|
||||
isSplittableAtCount,
|
||||
} from "./pane-split-availability"
|
||||
import { findSpawnTarget } from "./spawn-target-finder"
|
||||
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
|
||||
import { MIN_PANE_WIDTH } from "./types"
|
||||
|
||||
export function decideSpawnActions(
|
||||
state: WindowState,
|
||||
sessionId: string,
|
||||
description: string,
|
||||
_config: CapacityConfig,
|
||||
sessionMappings: SessionMapping[],
|
||||
): SpawnDecision {
|
||||
if (!state.mainPane) {
|
||||
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||
}
|
||||
|
||||
const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO))
|
||||
const currentCount = state.agentPanes.length
|
||||
|
||||
if (agentAreaWidth < MIN_PANE_WIDTH) {
|
||||
return {
|
||||
canSpawn: false,
|
||||
actions: [],
|
||||
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
|
||||
}
|
||||
}
|
||||
|
||||
const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings)
|
||||
const oldestMapping = oldestPane
|
||||
? sessionMappings.find((m) => m.paneId === oldestPane.paneId) ?? null
|
||||
: null
|
||||
|
||||
if (currentCount === 0) {
|
||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||
if (canSplitPane(virtualMainPane, "-h")) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: state.mainPane.paneId,
|
||||
splitDirection: "-h",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
||||
}
|
||||
|
||||
if (isSplittableAtCount(agentAreaWidth, currentCount)) {
|
||||
const spawnTarget = findSpawnTarget(state)
|
||||
if (spawnTarget) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: spawnTarget.targetPaneId,
|
||||
splitDirection: spawnTarget.splitDirection,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount)
|
||||
if (minEvictions === 1 && oldestPane) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
{
|
||||
type: "close",
|
||||
paneId: oldestPane.paneId,
|
||||
sessionId: oldestMapping?.sessionId || "",
|
||||
},
|
||||
{
|
||||
type: "spawn",
|
||||
sessionId,
|
||||
description,
|
||||
targetPaneId: state.mainPane.paneId,
|
||||
splitDirection: "-h",
|
||||
},
|
||||
],
|
||||
reason: "closed 1 pane to make room for split",
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestPane) {
|
||||
return {
|
||||
canSpawn: true,
|
||||
actions: [
|
||||
{
|
||||
type: "replace",
|
||||
paneId: oldestPane.paneId,
|
||||
oldSessionId: oldestMapping?.sessionId || "",
|
||||
newSessionId: sessionId,
|
||||
description,
|
||||
},
|
||||
],
|
||||
reason: "replaced oldest pane (no split possible)",
|
||||
}
|
||||
}
|
||||
|
||||
return { canSpawn: false, actions: [], reason: "no pane available to replace" }
|
||||
}
|
||||
|
||||
export function decideCloseAction(
|
||||
state: WindowState,
|
||||
sessionId: string,
|
||||
sessionMappings: SessionMapping[],
|
||||
): PaneAction | null {
|
||||
const mapping = sessionMappings.find((m) => m.sessionId === sessionId)
|
||||
if (!mapping) return null
|
||||
|
||||
const paneExists = state.agentPanes.some((pane) => pane.paneId === mapping.paneId)
|
||||
if (!paneExists) return null
|
||||
|
||||
return { type: "close", paneId: mapping.paneId, sessionId }
|
||||
}
|
||||
86
src/features/tmux-subagent/spawn-target-finder.ts
Normal file
86
src/features/tmux-subagent/spawn-target-finder.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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"
|
||||
|
||||
export interface SpawnTarget {
|
||||
targetPaneId: string
|
||||
splitDirection: SplitDirection
|
||||
}
|
||||
|
||||
function buildOccupancy(
|
||||
agentPanes: TmuxPaneInfo[],
|
||||
plan: ReturnType<typeof computeGridPlan>,
|
||||
mainPaneWidth: number,
|
||||
): Map<string, TmuxPaneInfo> {
|
||||
const occupancy = new Map<string, TmuxPaneInfo>()
|
||||
for (const pane of agentPanes) {
|
||||
const slot = mapPaneToSlot(pane, plan, mainPaneWidth)
|
||||
occupancy.set(`${slot.row}:${slot.col}`, pane)
|
||||
}
|
||||
return occupancy
|
||||
}
|
||||
|
||||
function findFirstEmptySlot(
|
||||
occupancy: Map<string, TmuxPaneInfo>,
|
||||
plan: ReturnType<typeof computeGridPlan>,
|
||||
): { row: number; col: number } {
|
||||
for (let row = 0; row < plan.rows; row++) {
|
||||
for (let col = 0; col < plan.cols; col++) {
|
||||
if (!occupancy.has(`${row}:${col}`)) {
|
||||
return { row, col }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row: plan.rows - 1, col: plan.cols - 1 }
|
||||
}
|
||||
|
||||
function findSplittableTarget(
|
||||
state: WindowState,
|
||||
_preferredDirection?: SplitDirection,
|
||||
): SpawnTarget | null {
|
||||
if (!state.mainPane) return null
|
||||
const existingCount = state.agentPanes.length
|
||||
|
||||
if (existingCount === 0) {
|
||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||
if (canSplitPane(virtualMainPane, "-h")) {
|
||||
return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1)
|
||||
const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO)
|
||||
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
||||
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
||||
|
||||
const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`)
|
||||
if (leftPane && canSplitPane(leftPane, "-h")) {
|
||||
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
||||
}
|
||||
|
||||
const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`)
|
||||
if (abovePane && canSplitPane(abovePane, "-v")) {
|
||||
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
||||
}
|
||||
|
||||
const splittablePanes = state.agentPanes
|
||||
.map((pane) => ({ pane, direction: getBestSplitDirection(pane) }))
|
||||
.filter(
|
||||
(item): item is { pane: TmuxPaneInfo; direction: SplitDirection } =>
|
||||
item.direction !== null,
|
||||
)
|
||||
.sort((a, b) => b.pane.width * b.pane.height - a.pane.width * a.pane.height)
|
||||
|
||||
const best = splittablePanes[0]
|
||||
if (best) {
|
||||
return { targetPaneId: best.pane.paneId, splitDirection: best.direction }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findSpawnTarget(state: WindowState): SpawnTarget | null {
|
||||
return findSplittableTarget(state)
|
||||
}
|
||||
10
src/features/tmux-subagent/tmux-grid-constants.ts
Normal file
10
src/features/tmux-subagent/tmux-grid-constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||
|
||||
export const MAIN_PANE_RATIO = 0.5
|
||||
export const MAX_COLS = 2
|
||||
export const MAX_ROWS = 3
|
||||
export const MAX_GRID_SIZE = 4
|
||||
export const DIVIDER_SIZE = 1
|
||||
|
||||
export const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
||||
export const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
||||
Reference in New Issue
Block a user