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:
parent
e3bd43ff64
commit
f8b5771443
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"
|
export type { SessionMapping } from "./oldest-agent-pane"
|
||||||
import { MIN_PANE_WIDTH, MIN_PANE_HEIGHT } from "./types"
|
export type { GridCapacity, GridPlan, GridSlot } from "./grid-planning"
|
||||||
|
export type { SpawnTarget } from "./spawn-target-finder"
|
||||||
|
|
||||||
export interface SessionMapping {
|
export {
|
||||||
sessionId: string
|
calculateCapacity,
|
||||||
paneId: string
|
computeGridPlan,
|
||||||
createdAt: Date
|
mapPaneToSlot,
|
||||||
}
|
} from "./grid-planning"
|
||||||
|
|
||||||
export interface GridCapacity {
|
export {
|
||||||
cols: number
|
canSplitPane,
|
||||||
rows: number
|
canSplitPaneAnyDirection,
|
||||||
total: number
|
findMinimalEvictions,
|
||||||
}
|
getBestSplitDirection,
|
||||||
|
getColumnCount,
|
||||||
|
getColumnWidth,
|
||||||
|
isSplittableAtCount,
|
||||||
|
} from "./pane-split-availability"
|
||||||
|
|
||||||
export interface GridSlot {
|
export { findSpawnTarget } from "./spawn-target-finder"
|
||||||
row: number
|
export { decideCloseAction, decideSpawnActions } from "./spawn-action-decider"
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
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 "./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 "./types"
|
||||||
export * from "./pane-state-querier"
|
export * from "./pane-state-querier"
|
||||||
export * from "./decision-engine"
|
export * from "./decision-engine"
|
||||||
|
|||||||
@ -4,23 +4,20 @@ import type { TrackedSession, CapacityConfig } from "./types"
|
|||||||
import {
|
import {
|
||||||
isInsideTmux as defaultIsInsideTmux,
|
isInsideTmux as defaultIsInsideTmux,
|
||||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||||
POLL_INTERVAL_BACKGROUND_MS,
|
|
||||||
SESSION_MISSING_GRACE_MS,
|
|
||||||
SESSION_READY_POLL_INTERVAL_MS,
|
|
||||||
SESSION_READY_TIMEOUT_MS,
|
|
||||||
} from "../../shared/tmux"
|
} from "../../shared/tmux"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { queryWindowState } from "./pane-state-querier"
|
import type { SessionMapping } from "./decision-engine"
|
||||||
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
import {
|
||||||
import { executeActions, executeAction } from "./action-executor"
|
coerceSessionCreatedEvent,
|
||||||
|
handleSessionCreated,
|
||||||
|
handleSessionDeleted,
|
||||||
|
type SessionCreatedEvent,
|
||||||
|
} from "./event-handlers"
|
||||||
|
import { createSessionPollingController, type SessionPollingController } from "./polling"
|
||||||
|
import { cleanupTmuxSessions } from "./cleanup"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
interface SessionCreatedEvent {
|
|
||||||
type: string
|
|
||||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TmuxUtilDeps {
|
export interface TmuxUtilDeps {
|
||||||
isInsideTmux: () => boolean
|
isInsideTmux: () => boolean
|
||||||
getCurrentPaneId: () => string | undefined
|
getCurrentPaneId: () => string | undefined
|
||||||
@ -31,13 +28,6 @@ const defaultTmuxDeps: TmuxUtilDeps = {
|
|||||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
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
|
* State-first Tmux Session Manager
|
||||||
*
|
*
|
||||||
@ -57,8 +47,8 @@ export class TmuxSessionManager {
|
|||||||
private sourcePaneId: string | undefined
|
private sourcePaneId: string | undefined
|
||||||
private sessions = new Map<string, TrackedSession>()
|
private sessions = new Map<string, TrackedSession>()
|
||||||
private pendingSessions = new Set<string>()
|
private pendingSessions = new Set<string>()
|
||||||
private pollInterval?: ReturnType<typeof setInterval>
|
|
||||||
private deps: TmuxUtilDeps
|
private deps: TmuxUtilDeps
|
||||||
|
private polling: SessionPollingController
|
||||||
|
|
||||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||||
this.client = ctx.client
|
this.client = ctx.client
|
||||||
@ -68,6 +58,14 @@ export class TmuxSessionManager {
|
|||||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||||
this.sourcePaneId = deps.getCurrentPaneId()
|
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", {
|
log("[tmux-session-manager] initialized", {
|
||||||
configEnabled: this.tmuxConfig.enabled,
|
configEnabled: this.tmuxConfig.enabled,
|
||||||
tmuxConfig: this.tmuxConfig,
|
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> {
|
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||||
const enabled = this.isEnabled()
|
await handleSessionCreated(
|
||||||
log("[tmux-session-manager] onSessionCreated called", {
|
{
|
||||||
enabled,
|
client: this.client,
|
||||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
tmuxConfig: this.tmuxConfig,
|
||||||
isInsideTmux: this.deps.isInsideTmux(),
|
serverUrl: this.serverUrl,
|
||||||
eventType: event.type,
|
sourcePaneId: this.sourcePaneId,
|
||||||
infoId: event.properties?.info?.id,
|
sessions: this.sessions,
|
||||||
infoParentID: event.properties?.info?.parentID,
|
pendingSessions: this.pendingSessions,
|
||||||
})
|
isInsideTmux: this.deps.isInsideTmux,
|
||||||
|
isEnabled: () => this.isEnabled(),
|
||||||
if (!enabled) return
|
getCapacityConfig: () => this.getCapacityConfig(),
|
||||||
if (event.type !== "session.created") return
|
getSessionMappings: () => this.getSessionMappings(),
|
||||||
|
waitForSessionReady: (sessionId) => this.polling.waitForSessionReady(sessionId),
|
||||||
const info = event.properties?.info
|
startPolling: () => this.polling.startPolling(),
|
||||||
if (!info?.id || !info?.parentID) return
|
},
|
||||||
|
event,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||||
if (!this.isEnabled()) return
|
await handleSessionDeleted(
|
||||||
if (!this.sourcePaneId) return
|
{
|
||||||
|
tmuxConfig: this.tmuxConfig,
|
||||||
const tracked = this.sessions.get(event.sessionID)
|
serverUrl: this.serverUrl,
|
||||||
if (!tracked) return
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
sessions: this.sessions,
|
||||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
isEnabled: () => this.isEnabled(),
|
||||||
|
getSessionMappings: () => this.getSessionMappings(),
|
||||||
const state = await queryWindowState(this.sourcePaneId)
|
stopPolling: () => this.polling.stopPolling(),
|
||||||
if (!state) {
|
},
|
||||||
this.sessions.delete(event.sessionID)
|
event,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
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> {
|
createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||||
return async (input) => {
|
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> {
|
async cleanup(): Promise<void> {
|
||||||
this.stopPolling()
|
await cleanupTmuxSessions({
|
||||||
|
tmuxConfig: this.tmuxConfig,
|
||||||
if (this.sessions.size > 0) {
|
serverUrl: this.serverUrl,
|
||||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
sourcePaneId: this.sourcePaneId,
|
||||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
sessions: this.sessions,
|
||||||
|
stopPolling: () => this.polling.stopPolling(),
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
Loading…
x
Reference in New Issue
Block a user