From f1316bc800712c0a387c0b342bec6d6bdd4e6d23 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 17:51:38 +0900 Subject: [PATCH] refactor(tmux-subagent): split manager.ts into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract polling logic to polling-manager.ts - Extract session cleanup to session-cleaner.ts - Extract session spawning to session-spawner.ts - Extract cleanup logic to manager-cleanup.ts - Reduce manager.ts from ~495 to ~345 lines - Follow modular code architecture (200 LOC limit) 🤖 Generated with assistance of OhMyOpenCode --- src/features/tmux-subagent/manager-cleanup.ts | 43 +++++ src/features/tmux-subagent/manager.ts | 150 ++-------------- src/features/tmux-subagent/polling-manager.ts | 139 +++++++++++++++ src/features/tmux-subagent/session-cleaner.ts | 80 +++++++++ src/features/tmux-subagent/session-spawner.ts | 166 ++++++++++++++++++ 5 files changed, 439 insertions(+), 139 deletions(-) create mode 100644 src/features/tmux-subagent/manager-cleanup.ts create mode 100644 src/features/tmux-subagent/polling-manager.ts create mode 100644 src/features/tmux-subagent/session-cleaner.ts create mode 100644 src/features/tmux-subagent/session-spawner.ts diff --git a/src/features/tmux-subagent/manager-cleanup.ts b/src/features/tmux-subagent/manager-cleanup.ts new file mode 100644 index 00000000..47ca4836 --- /dev/null +++ b/src/features/tmux-subagent/manager-cleanup.ts @@ -0,0 +1,43 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { executeAction } from "./action-executor" +import { TmuxPollingManager } from "./polling-manager" + +export class ManagerCleanup { + constructor( + private sessions: Map, + private sourcePaneId: string | undefined, + private pollingManager: TmuxPollingManager, + private tmuxConfig: TmuxConfig, + private serverUrl: string + ) {} + + async cleanup(): Promise { + this.pollingManager.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") + } +} diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index ad600dc5..d5e794a4 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -13,7 +13,7 @@ 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 { TmuxPollingManager } from "./polling-manager" type OpencodeClient = PluginInput["client"] interface SessionCreatedEvent { @@ -57,9 +57,8 @@ export class TmuxSessionManager { private sourcePaneId: string | undefined private sessions = new Map() private pendingSessions = new Set() - private pollInterval?: ReturnType private deps: TmuxUtilDeps - + private pollingManager: TmuxPollingManager constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { this.client = ctx.client this.tmuxConfig = tmuxConfig @@ -67,7 +66,11 @@ export class TmuxSessionManager { const defaultPort = process.env.OPENCODE_PORT ?? "4096" this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` this.sourcePaneId = deps.getCurrentPaneId() - + this.pollingManager = new TmuxPollingManager( + this.client, + this.sessions, + this.closeSessionById.bind(this) + ) log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, tmuxConfig: this.tmuxConfig, @@ -75,7 +78,6 @@ export class TmuxSessionManager { sourcePaneId: this.sourcePaneId, }) } - private isEnabled(): boolean { return this.tmuxConfig.enabled && this.deps.isInsideTmux() } @@ -239,7 +241,7 @@ export class TmuxSessionManager { paneId: result.spawnedPaneId, sessionReady, }) - this.startPolling() + this.pollingManager.startPolling() } else { log("[tmux-session-manager] spawn failed", { success: result.success, @@ -278,140 +280,10 @@ export class TmuxSessionManager { this.sessions.delete(event.sessionID) if (this.sessions.size === 0) { - this.stopPolling() + this.pollingManager.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 { - if (this.sessions.size === 0) { - this.stopPolling() - return - } - - try { - const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record - - 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 - 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 { const tracked = this.sessions.get(sessionId) @@ -433,7 +305,7 @@ export class TmuxSessionManager { this.sessions.delete(sessionId) if (this.sessions.size === 0) { - this.stopPolling() + this.pollingManager.stopPolling() } } @@ -444,7 +316,7 @@ export class TmuxSessionManager { } async cleanup(): Promise { - this.stopPolling() + this.pollingManager.stopPolling() if (this.sessions.size > 0) { log("[tmux-session-manager] closing all panes", { count: this.sessions.size }) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts new file mode 100644 index 00000000..0a73cdc7 --- /dev/null +++ b/src/features/tmux-subagent/polling-manager.ts @@ -0,0 +1,139 @@ +import type { OpencodeClient } from "../../tools/delegate-task/types" +import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux" +import type { TrackedSession } from "./types" +import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" +import { log } from "../../shared" + +const SESSION_TIMEOUT_MS = 10 * 60 * 1000 +const MIN_STABILITY_TIME_MS = 10 * 1000 +const STABLE_POLLS_REQUIRED = 3 + +export class TmuxPollingManager { + private pollInterval?: ReturnType + + constructor( + private client: OpencodeClient, + private sessions: Map, + private closeSessionById: (sessionId: string) => Promise + ) {} + + startPolling(): void { + if (this.pollInterval) return + + this.pollInterval = setInterval( + () => this.pollSessions(), + POLL_INTERVAL_BACKGROUND_MS, // POLL_INTERVAL_BACKGROUND_MS + ) + log("[tmux-session-manager] polling started") + } + + stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval) + this.pollInterval = undefined + log("[tmux-session-manager] polling stopped") + } + } + + private async pollSessions(): Promise { + if (this.sessions.size === 0) { + this.stopPolling() + return + } + + try { + const statusResult = await this.client.session.status({ path: undefined }) + const allStatuses = (statusResult.data ?? {}) as Record + + 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() + + let shouldCloseViaStability = false + + if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { + 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) { + tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 + + if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { + const recheckResult = await this.client.session.status({ path: undefined }) + const recheckStatuses = (recheckResult.data ?? {}) as Record + 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 = currentMsgCount + } catch (msgErr) { + log("[tmux-session-manager] failed to fetch messages for stability check", { + sessionId, + error: String(msgErr), + }) + } + } 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 this.closeSessionById(sessionId) + } + } catch (err) { + log("[tmux-session-manager] poll error", { error: String(err) }) + } + } +} diff --git a/src/features/tmux-subagent/session-cleaner.ts b/src/features/tmux-subagent/session-cleaner.ts new file mode 100644 index 00000000..d087433b --- /dev/null +++ b/src/features/tmux-subagent/session-cleaner.ts @@ -0,0 +1,80 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import type { SessionMapping } from "./decision-engine" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideCloseAction } from "./decision-engine" +import { executeAction } from "./action-executor" +import { TmuxPollingManager } from "./polling-manager" + +export interface TmuxUtilDeps { + isInsideTmux: () => boolean + getCurrentPaneId: () => string | undefined +} + +export class SessionCleaner { + constructor( + private tmuxConfig: TmuxConfig, + private deps: TmuxUtilDeps, + private sessions: Map, + private sourcePaneId: string | undefined, + private getSessionMappings: () => SessionMapping[], + private pollingManager: TmuxPollingManager, + private serverUrl: string + ) {} + + private isEnabled(): boolean { + return this.tmuxConfig.enabled && this.deps.isInsideTmux() + } + + async onSessionDeleted(event: { sessionID: string }): Promise { + 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.pollingManager.stopPolling() + } + } + + async closeSessionById(sessionId: string): Promise { + 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.pollingManager.stopPolling() + } + } +} diff --git a/src/features/tmux-subagent/session-spawner.ts b/src/features/tmux-subagent/session-spawner.ts new file mode 100644 index 00000000..433a163f --- /dev/null +++ b/src/features/tmux-subagent/session-spawner.ts @@ -0,0 +1,166 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession, CapacityConfig } 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 { TmuxPollingManager } from "./polling-manager" + +interface SessionCreatedEvent { + type: string + properties?: { info?: { id?: string; parentID?: string; title?: string } } +} + +export interface TmuxUtilDeps { + isInsideTmux: () => boolean + getCurrentPaneId: () => string | undefined +} + +export class SessionSpawner { + constructor( + private tmuxConfig: TmuxConfig, + private deps: TmuxUtilDeps, + private sessions: Map, + private pendingSessions: Set, + private sourcePaneId: string | undefined, + private getCapacityConfig: () => CapacityConfig, + private getSessionMappings: () => SessionMapping[], + private waitForSessionReady: (sessionId: string) => Promise, + private pollingManager: TmuxPollingManager, + private serverUrl: string + ) {} + + private isEnabled(): boolean { + return this.tmuxConfig.enabled && this.deps.isInsideTmux() + } + + async onSessionCreated(event: SessionCreatedEvent): Promise { + 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.pollingManager.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) + } + } +}