From 68aa9134998aefea7d6cf3fc4048406e3f6cd219 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Mon, 26 Jan 2026 12:02:37 +0900 Subject: [PATCH] refactor(tmux-subagent): state-first architecture with decision engine (#1125) * refactor(tmux-subagent): add state-first architecture with decision engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * feat(tmux): add pane spawn callbacks for background and sync sessions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --------- Co-authored-by: justsisyphus Co-authored-by: Sisyphus --- assets/oh-my-opencode.schema.json | 11 + src/config/schema.ts | 8 +- src/features/background-agent/manager.test.ts | 14 +- src/features/background-agent/manager.ts | 41 +- src/features/tmux-subagent/action-executor.ts | 66 ++ src/features/tmux-subagent/decision-engine.ts | 130 ++++ src/features/tmux-subagent/index.ts | 3 + src/features/tmux-subagent/manager.test.ts | 649 ++++++++++++++---- src/features/tmux-subagent/manager.ts | 436 +++++++++--- .../tmux-subagent/pane-state-querier.ts | 68 ++ src/features/tmux-subagent/types.ts | 46 ++ src/index.ts | 54 +- src/shared/tmux/constants.ts | 5 +- src/shared/tmux/tmux-utils.ts | 93 ++- src/tools/delegate-task/tools.ts | 21 +- 15 files changed, 1390 insertions(+), 255 deletions(-) create mode 100644 src/features/tmux-subagent/action-executor.ts create mode 100644 src/features/tmux-subagent/decision-engine.ts create mode 100644 src/features/tmux-subagent/pane-state-querier.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 0a29a9d5..d0f7da9a 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -71,6 +71,7 @@ "interactive-bash-session", "thinking-block-validator", "ralph-loop", + "category-skill-reminder", "compaction-context-injector", "claude-code-hooks", "auto-slash-command", @@ -2210,6 +2211,16 @@ "type": "number", "minimum": 20, "maximum": 80 + }, + "main_pane_min_width": { + "default": 120, + "type": "number", + "minimum": 40 + }, + "agent_pane_min_width": { + "default": 40, + "type": "number", + "minimum": 20 } } } diff --git a/src/config/schema.ts b/src/config/schema.ts index 7da8b15e..06a3217d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -320,9 +320,11 @@ export const TmuxLayoutSchema = z.enum([ ]) export const TmuxConfigSchema = z.object({ - enabled: z.boolean().default(false), // default: false (disabled) - layout: TmuxLayoutSchema.default('main-vertical'), // default: main-vertical - main_pane_size: z.number().min(20).max(80).default(60), // percentage, default: 60% + enabled: z.boolean().default(false), + layout: TmuxLayoutSchema.default('main-vertical'), + main_pane_size: z.number().min(20).max(80).default(60), + main_pane_min_width: z.number().min(40).default(120), + agent_pane_min_width: z.number().min(20).default(40), }) export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 3ac87fc0..3cc7569d 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -776,7 +776,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => parentModel: { providerID: "old", modelID: "old-model" }, } const currentMessage: CurrentMessage = { - agent: "Sisyphus", + agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4-5" }, } @@ -784,7 +784,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => const promptBody = buildNotificationPromptBody(task, currentMessage) // #then - uses currentMessage values, not task.parentModel/parentAgent - expect(promptBody.agent).toBe("Sisyphus") + expect(promptBody.agent).toBe("sisyphus") expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-5" }) }) @@ -827,11 +827,11 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => status: "completed", startedAt: new Date(), completedAt: new Date(), - parentAgent: "Sisyphus", + parentAgent: "sisyphus", parentModel: { providerID: "anthropic", modelID: "claude-opus" }, } const currentMessage: CurrentMessage = { - agent: "Sisyphus", + agent: "sisyphus", model: { providerID: "anthropic" }, } @@ -839,7 +839,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => const promptBody = buildNotificationPromptBody(task, currentMessage) // #then - model not passed due to incomplete data - expect(promptBody.agent).toBe("Sisyphus") + expect(promptBody.agent).toBe("sisyphus") expect("model" in promptBody).toBe(false) }) @@ -856,7 +856,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => status: "completed", startedAt: new Date(), completedAt: new Date(), - parentAgent: "Sisyphus", + parentAgent: "sisyphus", parentModel: { providerID: "anthropic", modelID: "claude-opus" }, } @@ -864,7 +864,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => const promptBody = buildNotificationPromptBody(task, null) // #then - falls back to task.parentAgent, no model - expect(promptBody.agent).toBe("Sisyphus") + expect(promptBody.agent).toBe("sisyphus") expect("model" in promptBody).toBe(false) }) }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index a8dbc945..715bb6c3 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -55,6 +55,14 @@ interface QueueItem { input: LaunchInput } +export interface SubagentSessionCreatedEvent { + sessionID: string + parentID: string + title: string +} + +export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise + export class BackgroundManager { private static cleanupManagers = new Set() private static cleanupRegistered = false @@ -70,6 +78,7 @@ export class BackgroundManager { private shutdownTriggered = false private config?: BackgroundTaskConfig private tmuxEnabled: boolean + private onSubagentSessionCreated?: OnSubagentSessionCreated private queuesByKey: Map = new Map() private processingKeys: Set = new Set() @@ -77,7 +86,10 @@ export class BackgroundManager { constructor( ctx: PluginInput, config?: BackgroundTaskConfig, - tmuxConfig?: TmuxConfig + options?: { + tmuxConfig?: TmuxConfig + onSubagentSessionCreated?: OnSubagentSessionCreated + } ) { this.tasks = new Map() this.notifications = new Map() @@ -86,7 +98,8 @@ export class BackgroundManager { this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) this.config = config - this.tmuxEnabled = tmuxConfig?.enabled ?? false + this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false + this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.registerProcessCleanup() } @@ -228,9 +241,27 @@ export class BackgroundManager { const sessionID = createResult.data.id subagentSessions.add(sessionID) - // Wait for TmuxSessionManager to spawn pane via event hook - if (this.tmuxEnabled && isInsideTmux()) { - await new Promise(r => setTimeout(r, 500)) + log("[background-agent] tmux callback check", { + hasCallback: !!this.onSubagentSessionCreated, + tmuxEnabled: this.tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID: input.parentSessionID, + }) + + if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await this.onSubagentSessionCreated({ + sessionID, + parentID: input.parentSessionID, + title: input.description, + }).catch((err) => { + log("[background-agent] Failed to spawn tmux pane:", err) + }) + log("[background-agent] tmux callback completed, waiting 200ms") + await new Promise(r => setTimeout(r, 200)) + } else { + log("[background-agent] SKIP tmux callback - conditions not met") } // Update task to running state diff --git a/src/features/tmux-subagent/action-executor.ts b/src/features/tmux-subagent/action-executor.ts new file mode 100644 index 00000000..151329f0 --- /dev/null +++ b/src/features/tmux-subagent/action-executor.ts @@ -0,0 +1,66 @@ +import type { TmuxConfig } from "../../config/schema" +import type { PaneAction } from "./types" +import { spawnTmuxPane, closeTmuxPane } from "../../shared/tmux" +import { log } from "../../shared" + +export interface ActionResult { + success: boolean + paneId?: string + error?: string +} + +export interface ExecuteActionsResult { + success: boolean + spawnedPaneId?: string + results: Array<{ action: PaneAction; result: ActionResult }> +} + +export async function executeAction( + action: PaneAction, + config: TmuxConfig, + serverUrl: string +): Promise { + if (action.type === "close") { + const success = await closeTmuxPane(action.paneId) + return { success } + } + + const result = await spawnTmuxPane( + action.sessionId, + action.description, + config, + serverUrl, + action.targetPaneId + ) + + return { + success: result.success, + paneId: result.paneId, + } +} + +export async function executeActions( + actions: PaneAction[], + config: TmuxConfig, + serverUrl: string +): Promise { + const results: Array<{ action: PaneAction; result: ActionResult }> = [] + let spawnedPaneId: string | undefined + + for (const action of actions) { + log("[action-executor] executing", { type: action.type }) + const result = await executeAction(action, config, serverUrl) + results.push({ action, result }) + + if (!result.success) { + log("[action-executor] action failed", { type: action.type, error: result.error }) + return { success: false, results } + } + + if (action.type === "spawn" && result.paneId) { + spawnedPaneId = result.paneId + } + } + + return { success: true, spawnedPaneId, results } +} diff --git a/src/features/tmux-subagent/decision-engine.ts b/src/features/tmux-subagent/decision-engine.ts new file mode 100644 index 00000000..4a1ac960 --- /dev/null +++ b/src/features/tmux-subagent/decision-engine.ts @@ -0,0 +1,130 @@ +import type { WindowState, PaneAction, SpawnDecision, CapacityConfig } from "./types" + +export interface SessionMapping { + sessionId: string + paneId: string + createdAt: Date +} + +export function calculateCapacity( + windowWidth: number, + config: CapacityConfig +): number { + const availableForAgents = windowWidth - config.mainPaneMinWidth + if (availableForAgents <= 0) return 0 + return Math.floor(availableForAgents / config.agentPaneWidth) +} + +function calculateAvailableWidth( + windowWidth: number, + mainPaneMinWidth: number, + agentPaneCount: number, + agentPaneWidth: number +): number { + const usedByAgents = agentPaneCount * agentPaneWidth + return windowWidth - mainPaneMinWidth - usedByAgents +} + +function findOldestSession(mappings: SessionMapping[]): SessionMapping | null { + if (mappings.length === 0) return null + return mappings.reduce((oldest, current) => + current.createdAt < oldest.createdAt ? current : oldest + ) +} + +function getRightmostPane(state: WindowState): string { + if (state.agentPanes.length > 0) { + const rightmost = state.agentPanes.reduce((r, p) => (p.left > r.left ? p : r)) + return rightmost.paneId + } + return state.mainPane?.paneId ?? "" +} + +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 availableWidth = calculateAvailableWidth( + state.windowWidth, + config.mainPaneMinWidth, + state.agentPanes.length, + config.agentPaneWidth + ) + + if (availableWidth >= config.agentPaneWidth) { + const targetPaneId = getRightmostPane(state) + return { + canSpawn: true, + actions: [ + { + type: "spawn", + sessionId, + description, + targetPaneId, + }, + ], + } + } + + if (state.agentPanes.length > 0) { + const oldest = findOldestSession(sessionMappings) + + if (oldest) { + return { + canSpawn: true, + actions: [ + { type: "close", paneId: oldest.paneId, sessionId: oldest.sessionId }, + { + type: "spawn", + sessionId, + description, + targetPaneId: state.mainPane.paneId, + }, + ], + reason: "closing oldest session to make room", + } + } + + const leftmostPane = state.agentPanes.reduce((l, p) => (p.left < l.left ? p : l)) + return { + canSpawn: true, + actions: [ + { type: "close", paneId: leftmostPane.paneId, sessionId: "" }, + { + type: "spawn", + sessionId, + description, + targetPaneId: state.mainPane.paneId, + }, + ], + reason: "closing leftmost pane to make room", + } + } + + return { + canSpawn: false, + actions: [], + reason: `window too narrow: available=${availableWidth}, needed=${config.agentPaneWidth}`, + } +} + +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 } +} diff --git a/src/features/tmux-subagent/index.ts b/src/features/tmux-subagent/index.ts index 72836125..2b250087 100644 --- a/src/features/tmux-subagent/index.ts +++ b/src/features/tmux-subagent/index.ts @@ -1,2 +1,5 @@ export * from "./manager" export * from "./types" +export * from "./pane-state-querier" +export * from "./decision-engine" +export * from "./action-executor" diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 758ad870..8e241e70 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -1,21 +1,69 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test' import type { TmuxConfig } from '../../config/schema' +import type { WindowState, PaneAction } from './types' +import type { ActionResult } from './action-executor' -// Mock setup - tmux-utils functions -const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: '%mock' })) -const mockCloseTmuxPane = mock(async () => true) -const mockIsInsideTmux = mock(() => true) +type ExecuteActionsResult = { + success: boolean + spawnedPaneId?: string + results: Array<{ action: PaneAction; result: ActionResult }> +} + +const mockQueryWindowState = mock<(paneId: string) => Promise>( + async () => ({ + windowWidth: 200, + mainPane: { paneId: '%0', width: 120, left: 0, title: 'main', isActive: true }, + agentPanes: [], + }) +) +const mockPaneExists = mock<(paneId: string) => Promise>(async () => true) +const mockExecuteActions = mock<( + actions: PaneAction[], + config: TmuxConfig, + serverUrl: string +) => Promise>(async () => ({ + success: true, + spawnedPaneId: '%mock', + results: [], +})) +const mockExecuteAction = mock<( + action: PaneAction, + config: TmuxConfig, + serverUrl: string +) => Promise>(async () => ({ success: true })) +const mockIsInsideTmux = mock<() => boolean>(() => true) +const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0') + +mock.module('./pane-state-querier', () => ({ + queryWindowState: mockQueryWindowState, + paneExists: mockPaneExists, + getRightmostAgentPane: (state: WindowState) => + state.agentPanes.length > 0 + ? state.agentPanes.reduce((r, p) => (p.left > r.left ? p : r)) + : null, + getOldestAgentPane: (state: WindowState) => + state.agentPanes.length > 0 + ? state.agentPanes.reduce((o, p) => (p.left < o.left ? p : o)) + : null, +})) + +mock.module('./action-executor', () => ({ + executeActions: mockExecuteActions, + executeAction: mockExecuteAction, +})) mock.module('../../shared/tmux', () => ({ - spawnTmuxPane: mockSpawnTmuxPane, - closeTmuxPane: mockCloseTmuxPane, isInsideTmux: mockIsInsideTmux, + getCurrentPaneId: mockGetCurrentPaneId, POLL_INTERVAL_BACKGROUND_MS: 2000, SESSION_TIMEOUT_MS: 600000, SESSION_MISSING_GRACE_MS: 6000, + SESSION_READY_POLL_INTERVAL_MS: 100, + SESSION_READY_TIMEOUT_MS: 500, })) -// Mock context helper +const trackedSessions = new Set() + function createMockContext(overrides?: { sessionStatusResult?: { data?: Record } }) { @@ -23,23 +71,71 @@ function createMockContext(overrides?: { serverUrl: new URL('http://localhost:4096'), client: { session: { - status: mock(async () => overrides?.sessionStatusResult ?? { data: {} }), + status: mock(async () => { + if (overrides?.sessionStatusResult) { + return overrides.sessionStatusResult + } + const data: Record = {} + for (const sessionId of trackedSessions) { + data[sessionId] = { type: 'running' } + } + return { data } + }), }, }, } as any } +function createSessionCreatedEvent( + id: string, + parentID: string | undefined, + title: string +) { + return { + type: 'session.created', + properties: { + info: { id, parentID, title }, + }, + } +} + +function createWindowState(overrides?: Partial): WindowState { + return { + windowWidth: 200, + mainPane: { paneId: '%0', width: 120, left: 0, title: 'main', isActive: true }, + agentPanes: [], + ...overrides, + } +} + describe('TmuxSessionManager', () => { beforeEach(() => { - // Reset mocks before each test - mockSpawnTmuxPane.mockClear() - mockCloseTmuxPane.mockClear() + mockQueryWindowState.mockClear() + mockPaneExists.mockClear() + mockExecuteActions.mockClear() + mockExecuteAction.mockClear() mockIsInsideTmux.mockClear() + mockGetCurrentPaneId.mockClear() + trackedSessions.clear() + + mockQueryWindowState.mockImplementation(async () => createWindowState()) + mockExecuteActions.mockImplementation(async (actions) => { + for (const action of actions) { + if (action.type === 'spawn') { + trackedSessions.add(action.sessionId) + } + } + return { + success: true, + spawnedPaneId: '%mock', + results: [], + } + }) }) describe('constructor', () => { test('enabled when config.enabled=true and isInsideTmux=true', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -47,17 +143,19 @@ describe('TmuxSessionManager', () => { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } - // #when + //#when const manager = new TmuxSessionManager(ctx, config) - // #then + //#then expect(manager).toBeDefined() }) test('disabled when config.enabled=true but isInsideTmux=false', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(false) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -65,17 +163,19 @@ describe('TmuxSessionManager', () => { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } - // #when + //#when const manager = new TmuxSessionManager(ctx, config) - // #then + //#then expect(manager).toBeDefined() }) test('disabled when config.enabled=false', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -83,50 +183,120 @@ describe('TmuxSessionManager', () => { enabled: false, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } - // #when + //#when const manager = new TmuxSessionManager(ctx, config) - // #then + //#then expect(manager).toBeDefined() }) }) describe('onSessionCreated', () => { - test('spawns pane when session has parentID', async () => { - // #given + test('first agent spawns from source pane via decision engine', async () => { + //#given mockIsInsideTmux.mockReturnValue(true) + mockQueryWindowState.mockImplementation(async () => createWindowState()) + const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() const config: TmuxConfig = { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + const manager = new TmuxSessionManager(ctx, config) + const event = createSessionCreatedEvent( + 'ses_child', + 'ses_parent', + 'Background: Test Task' + ) + + //#when + await manager.onSessionCreated(event) + + //#then + expect(mockQueryWindowState).toHaveBeenCalledTimes(1) + expect(mockExecuteActions).toHaveBeenCalledTimes(1) + + const call = mockExecuteActions.mock.calls[0] + expect(call).toBeDefined() + const actionsArg = call![0] + expect(actionsArg).toHaveLength(1) + expect(actionsArg[0]).toEqual({ + type: 'spawn', + sessionId: 'ses_child', + description: 'Background: Test Task', + targetPaneId: '%0', + }) + }) + + test('second agent spawns from last agent pane', async () => { + //#given + mockIsInsideTmux.mockReturnValue(true) + + let callCount = 0 + mockQueryWindowState.mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return createWindowState() + } + return createWindowState({ + agentPanes: [ + { + paneId: '%1', + width: 40, + left: 120, + title: 'omo-subagent-Task 1', + isActive: false, + }, + ], + }) + }) + + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) - const event = { - sessionID: 'ses_child', - parentID: 'ses_parent', - title: 'Background: Test Task', - } - - // #when - await manager.onSessionCreated(event) - - // #then - expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1) - expect(mockSpawnTmuxPane).toHaveBeenCalledWith( - 'ses_child', - 'Background: Test Task', - config, - 'http://localhost:4096' + //#when - first agent + await manager.onSessionCreated( + createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1') ) + mockExecuteActions.mockClear() + + //#when - second agent + await manager.onSessionCreated( + createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2') + ) + + //#then - second agent targets the last agent pane (%1) + expect(mockExecuteActions).toHaveBeenCalledTimes(1) + const call = mockExecuteActions.mock.calls[0] + expect(call).toBeDefined() + const actionsArg = call![0] + expect(actionsArg).toHaveLength(1) + expect(actionsArg[0]).toEqual({ + type: 'spawn', + sessionId: 'ses_2', + description: 'Task 2', + targetPaneId: '%1', + }) }) test('does NOT spawn pane when session has no parentID', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -134,24 +304,21 @@ describe('TmuxSessionManager', () => { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) + const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session') - const event = { - sessionID: 'ses_root', - parentID: undefined, - title: 'Root Session', - } - - // #when + //#when await manager.onSessionCreated(event) - // #then - expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0) + //#then + expect(mockExecuteActions).toHaveBeenCalledTimes(0) }) test('does NOT spawn pane when disabled', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -159,52 +326,160 @@ describe('TmuxSessionManager', () => { enabled: false, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + const manager = new TmuxSessionManager(ctx, config) + const event = createSessionCreatedEvent( + 'ses_child', + 'ses_parent', + 'Background: Test Task' + ) + + //#when + await manager.onSessionCreated(event) + + //#then + expect(mockExecuteActions).toHaveBeenCalledTimes(0) + }) + + test('does NOT spawn pane for non session.created event type', async () => { + //#given + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + } + const manager = new TmuxSessionManager(ctx, config) + const event = { + type: 'session.deleted', + properties: { + info: { id: 'ses_child', parentID: 'ses_parent', title: 'Task' }, + }, + } + + //#when + await manager.onSessionCreated(event) + + //#then + expect(mockExecuteActions).toHaveBeenCalledTimes(0) + }) + + test('closes oldest agent when at max capacity', async () => { + //#given + mockIsInsideTmux.mockReturnValue(true) + mockQueryWindowState.mockImplementation(async () => + createWindowState({ + windowWidth: 160, + agentPanes: [ + { + paneId: '%1', + width: 40, + left: 120, + title: 'omo-subagent-Task 1', + isActive: false, + }, + ], + }) + ) + + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) - const event = { - sessionID: 'ses_child', - parentID: 'ses_parent', - title: 'Background: Test Task', - } + //#when + await manager.onSessionCreated( + createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task') + ) - // #when - await manager.onSessionCreated(event) + //#then + expect(mockExecuteActions).toHaveBeenCalledTimes(1) + const call = mockExecuteActions.mock.calls[0] + expect(call).toBeDefined() + const actionsArg = call![0] + expect(actionsArg.length).toBeGreaterThanOrEqual(1) - // #then - expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0) + const closeActions = actionsArg.filter((a) => a.type === 'close') + const spawnActions = actionsArg.filter((a) => a.type === 'spawn') + + expect(closeActions).toHaveLength(1) + expect((closeActions[0] as any).paneId).toBe('%1') + expect(spawnActions).toHaveLength(1) }) }) describe('onSessionDeleted', () => { test('closes pane when tracked session is deleted', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) + + let stateCallCount = 0 + mockQueryWindowState.mockImplementation(async () => { + stateCallCount++ + if (stateCallCount === 1) { + return createWindowState() + } + return createWindowState({ + agentPanes: [ + { + paneId: '%mock', + width: 40, + left: 120, + title: 'omo-subagent-Task', + isActive: false, + }, + ], + }) + }) + const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() const config: TmuxConfig = { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) - // First create a session (to track it) - await manager.onSessionCreated({ - sessionID: 'ses_child', - parentID: 'ses_parent', - title: 'Background: Test Task', - }) + await manager.onSessionCreated( + createSessionCreatedEvent( + 'ses_child', + 'ses_parent', + 'Background: Test Task' + ) + ) + mockExecuteAction.mockClear() - // #when + //#when await manager.onSessionDeleted({ sessionID: 'ses_child' }) - // #then - expect(mockCloseTmuxPane).toHaveBeenCalledTimes(1) + //#then + expect(mockExecuteAction).toHaveBeenCalledTimes(1) + const call = mockExecuteAction.mock.calls[0] + expect(call).toBeDefined() + expect(call![0]).toEqual({ + type: 'close', + paneId: '%mock', + sessionId: 'ses_child', + }) }) test('does nothing when untracked session is deleted', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() @@ -212,88 +487,208 @@ describe('TmuxSessionManager', () => { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) - // #when + //#when await manager.onSessionDeleted({ sessionID: 'ses_unknown' }) - // #then - expect(mockCloseTmuxPane).toHaveBeenCalledTimes(0) - }) - }) - - describe('pollSessions', () => { - test('closes pane when session becomes idle', async () => { - // #given - mockIsInsideTmux.mockReturnValue(true) - const { TmuxSessionManager } = await import('./manager') - - // Mock session.status to return idle session - const ctx = createMockContext({ - sessionStatusResult: { - data: { - ses_child: { type: 'idle' }, - }, - }, - }) - - const config: TmuxConfig = { - enabled: true, - layout: 'main-vertical', - main_pane_size: 60, - } - const manager = new TmuxSessionManager(ctx, config) - - // Create tracked session - await manager.onSessionCreated({ - sessionID: 'ses_child', - parentID: 'ses_parent', - title: 'Background: Test Task', - }) - - mockCloseTmuxPane.mockClear() // Clear spawn call - - // #when - await manager.pollSessions() - - // #then - expect(mockCloseTmuxPane).toHaveBeenCalledTimes(1) + //#then + expect(mockExecuteAction).toHaveBeenCalledTimes(0) }) }) describe('cleanup', () => { test('closes all tracked panes', async () => { - // #given + //#given mockIsInsideTmux.mockReturnValue(true) + + let callCount = 0 + mockExecuteActions.mockImplementation(async () => { + callCount++ + return { + success: true, + spawnedPaneId: `%${callCount}`, + results: [], + } + }) + const { TmuxSessionManager } = await import('./manager') const ctx = createMockContext() const config: TmuxConfig = { enabled: true, layout: 'main-vertical', main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, } const manager = new TmuxSessionManager(ctx, config) - // Track multiple sessions - await manager.onSessionCreated({ - sessionID: 'ses_1', - parentID: 'ses_parent', - title: 'Task 1', - }) - await manager.onSessionCreated({ - sessionID: 'ses_2', - parentID: 'ses_parent', - title: 'Task 2', - }) + await manager.onSessionCreated( + createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1') + ) + await manager.onSessionCreated( + createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2') + ) - mockCloseTmuxPane.mockClear() + mockExecuteAction.mockClear() - // #when + //#when await manager.cleanup() - // #then - expect(mockCloseTmuxPane).toHaveBeenCalledTimes(2) + //#then + expect(mockExecuteAction).toHaveBeenCalledTimes(2) + }) + }) +}) + +describe('DecisionEngine', () => { + describe('calculateCapacity', () => { + test('calculates correct max agents for given window width', async () => { + //#given + const { calculateCapacity } = await import('./decision-engine') + + //#when + const result = calculateCapacity(200, { + mainPaneMinWidth: 120, + agentPaneWidth: 40, + }) + + //#then + expect(result).toBe(2) + }) + + test('returns 0 when window is too narrow', async () => { + //#given + const { calculateCapacity } = await import('./decision-engine') + + //#when + const result = calculateCapacity(100, { + mainPaneMinWidth: 120, + agentPaneWidth: 40, + }) + + //#then + expect(result).toBe(0) + }) + }) + + describe('decideSpawnActions', () => { + test('returns spawn action when under capacity', async () => { + //#given + const { decideSpawnActions } = await import('./decision-engine') + const state: WindowState = { + windowWidth: 200, + mainPane: { + paneId: '%0', + width: 120, + left: 0, + title: 'main', + isActive: true, + }, + agentPanes: [], + } + + //#when + const decision = decideSpawnActions( + state, + 'ses_1', + 'Test Task', + { mainPaneMinWidth: 120, agentPaneWidth: 40 }, + [] + ) + + //#then + expect(decision.canSpawn).toBe(true) + expect(decision.actions).toHaveLength(1) + expect(decision.actions[0]).toEqual({ + type: 'spawn', + sessionId: 'ses_1', + description: 'Test Task', + targetPaneId: '%0', + }) + }) + + test('returns close + spawn when at capacity', async () => { + //#given + const { decideSpawnActions } = await import('./decision-engine') + const state: WindowState = { + windowWidth: 160, + mainPane: { + paneId: '%0', + width: 120, + left: 0, + title: 'main', + isActive: true, + }, + agentPanes: [ + { + paneId: '%1', + width: 40, + left: 120, + title: 'omo-subagent-Old', + isActive: false, + }, + ], + } + const sessionMappings = [ + { sessionId: 'ses_old', paneId: '%1', createdAt: new Date('2024-01-01') }, + ] + + //#when + const decision = decideSpawnActions( + state, + 'ses_new', + 'New Task', + { mainPaneMinWidth: 120, agentPaneWidth: 40 }, + sessionMappings + ) + + //#then + expect(decision.canSpawn).toBe(true) + expect(decision.actions).toHaveLength(2) + expect(decision.actions[0]).toEqual({ + type: 'close', + paneId: '%1', + sessionId: 'ses_old', + }) + expect(decision.actions[1]).toEqual({ + type: 'spawn', + sessionId: 'ses_new', + description: 'New Task', + targetPaneId: '%0', + }) + }) + + test('returns canSpawn=false when window too narrow', async () => { + //#given + const { decideSpawnActions } = await import('./decision-engine') + const state: WindowState = { + windowWidth: 100, + mainPane: { + paneId: '%0', + width: 100, + left: 0, + title: 'main', + isActive: true, + }, + agentPanes: [], + } + + //#when + const decision = decideSpawnActions( + state, + 'ses_1', + 'Test Task', + { mainPaneMinWidth: 120, agentPaneWidth: 40 }, + [] + ) + + //#then + expect(decision.canSpawn).toBe(false) + expect(decision.reason).toContain('too narrow') }) }) }) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index f6e72db4..202f69cd 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -1,127 +1,385 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" -import type { TrackedSession } from "./types" +import type { TrackedSession, CapacityConfig } from "./types" import { - spawnTmuxPane, - closeTmuxPane, isInsideTmux, + getCurrentPaneId, POLL_INTERVAL_BACKGROUND_MS, SESSION_MISSING_GRACE_MS, + SESSION_READY_POLL_INTERVAL_MS, + SESSION_READY_TIMEOUT_MS, } from "../../shared/tmux" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" +import { executeActions, executeAction } from "./action-executor" +type OpencodeClient = PluginInput["client"] + +interface SessionCreatedEvent { + type: string + properties?: { info?: { id?: string; parentID?: string; title?: string } } +} + +const SESSION_TIMEOUT_MS = 10 * 60 * 1000 + +/** + * State-first Tmux Session Manager + * + * Architecture: + * 1. QUERY: Get actual tmux pane state (source of truth) + * 2. DECIDE: Pure function determines actions based on state + * 3. EXECUTE: Execute actions with verification + * 4. UPDATE: Update internal cache only after tmux confirms success + * + * The internal `sessions` Map is just a cache for sessionId<->paneId mapping. + * The REAL source of truth is always queried from tmux. + */ export class TmuxSessionManager { - private enabled: boolean - private sessions: Map + private client: OpencodeClient + private tmuxConfig: TmuxConfig private serverUrl: string - private config: TmuxConfig - private ctx: PluginInput - private pollingInterval: ReturnType | null = null + private sourcePaneId: string | undefined + private sessions = new Map() + private pendingSessions = new Set() + private pollInterval?: ReturnType constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) { - this.ctx = ctx - this.config = tmuxConfig - this.sessions = new Map() - - this.enabled = tmuxConfig.enabled && isInsideTmux() - + this.client = ctx.client + this.tmuxConfig = tmuxConfig const defaultPort = process.env.OPENCODE_PORT ?? "4096" - const urlString = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` - this.serverUrl = urlString.endsWith("/") ? urlString.slice(0, -1) : urlString + this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` + this.sourcePaneId = getCurrentPaneId() - if (this.enabled) { - this.startPolling() + log("[tmux-session-manager] initialized", { + configEnabled: this.tmuxConfig.enabled, + tmuxConfig: this.tmuxConfig, + serverUrl: this.serverUrl, + sourcePaneId: this.sourcePaneId, + }) + } + + private isEnabled(): boolean { + return this.tmuxConfig.enabled && isInsideTmux() + } + + private getCapacityConfig(): CapacityConfig { + return { + mainPaneMinWidth: this.tmuxConfig.main_pane_min_width, + agentPaneWidth: this.tmuxConfig.agent_pane_min_width, } } - async onSessionCreated(event: { - sessionID: string - parentID?: string - title: string - }): Promise { - if (!this.enabled) return - if (!event.parentID) return + private getSessionMappings(): SessionMapping[] { + return Array.from(this.sessions.values()).map((s) => ({ + sessionId: s.sessionId, + paneId: s.paneId, + createdAt: s.createdAt, + })) + } - const result = await spawnTmuxPane( - event.sessionID, - event.title, - this.config, - this.serverUrl - ) + private async waitForSessionReady(sessionId: string): Promise { + 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 + + 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 + } - if (result.success && result.paneId) { - this.sessions.set(event.sessionID, { - sessionId: event.sessionID, - paneId: result.paneId, - description: event.title, - createdAt: new Date(), - lastSeenAt: new Date(), + async onSessionCreated(event: SessionCreatedEvent): Promise { + const enabled = this.isEnabled() + log("[tmux-session-manager] onSessionCreated called", { + enabled, + tmuxConfigEnabled: this.tmuxConfig.enabled, + isInsideTmux: 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) => + a.type === "close" + ? { type: "close", paneId: a.paneId } + : { type: "spawn", sessionId: a.sessionId } + ), + }) + + if (!decision.canSpawn) { + log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) + return + } + + const result = await executeActions( + decision.actions, + this.tmuxConfig, + this.serverUrl + ) + + 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 (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 { - if (!this.enabled) return + if (!this.isEnabled()) return + if (!this.sourcePaneId) return const tracked = this.sessions.get(event.sessionID) if (!tracked) return - await this.closeSession(event.sessionID) - } + log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID }) - async pollSessions(): Promise { - if (!this.enabled) return - if (this.sessions.size === 0) return - - try { - const statusResult = await this.ctx.client.session.status({ path: undefined }) - const statuses = (statusResult.data ?? {}) as Record - - for (const [sessionId, tracked] of this.sessions.entries()) { - const status = statuses[sessionId] - - if (!status) { - const missingSince = Date.now() - tracked.lastSeenAt.getTime() - if (missingSince > SESSION_MISSING_GRACE_MS) { - await this.closeSession(sessionId) - } - continue - } - - tracked.lastSeenAt = new Date() - - if (status.type === "idle") { - await this.closeSession(sessionId) - } - } - } catch { - // Ignore errors - } - } - - async closeSession(sessionId: string): Promise { - const tracked = this.sessions.get(sessionId) - if (!tracked) return - - await closeTmuxPane(tracked.paneId) - this.sessions.delete(sessionId) - } - - async cleanup(): Promise { - if (this.pollingInterval) { - clearInterval(this.pollingInterval) - this.pollingInterval = null + const state = await queryWindowState(this.sourcePaneId) + if (!state) { + this.sessions.delete(event.sessionID) + return } - for (const sessionId of Array.from(this.sessions.keys())) { - await this.closeSession(sessionId) + const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings()) + if (closeAction) { + await executeAction(closeAction, this.tmuxConfig, this.serverUrl) + } + + this.sessions.delete(event.sessionID) + + if (this.sessions.size === 0) { + this.stopPolling() } } private startPolling(): void { - this.pollingInterval = setInterval(() => { - this.pollSessions().catch(() => { - // Ignore errors + 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), }) - }, POLL_INTERVAL_BACKGROUND_MS) + + 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 + + log("[tmux-session-manager] session check", { + sessionId, + statusType: status?.type, + isIdle, + missingSince, + missingTooLong, + isTimedOut, + shouldClose: isIdle || missingTooLong || isTimedOut, + }) + + if (isIdle || 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) + if (!tracked) return + + log("[tmux-session-manager] closing session pane", { + sessionId, + paneId: tracked.paneId, + }) + + await executeAction( + { type: "close", paneId: tracked.paneId, sessionId }, + this.tmuxConfig, + this.serverUrl + ) + + this.sessions.delete(sessionId) + + if (this.sessions.size === 0) { + this.stopPolling() + } + } + + createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise { + return async (input) => { + await this.onSessionCreated(input.event as SessionCreatedEvent) + } + } + + async cleanup(): Promise { + this.stopPolling() + + if (this.sessions.size > 0) { + log("[tmux-session-manager] closing all panes", { count: this.sessions.size }) + const closePromises = Array.from(this.sessions.values()).map((s) => + executeAction( + { type: "close", paneId: s.paneId, sessionId: s.sessionId }, + this.tmuxConfig, + this.serverUrl + ).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/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts new file mode 100644 index 00000000..3153989f --- /dev/null +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -0,0 +1,68 @@ +import { spawn } from "bun" +import type { WindowState, TmuxPaneInfo } from "./types" +import { getTmuxPath } from "../../tools/interactive-bash/utils" +import { log } from "../../shared" + +/** + * Query the current window state from tmux. + * This is the source of truth - not our internal cache. + */ +export async function queryWindowState(sourcePaneId: string): Promise { + const tmux = await getTmuxPath() + if (!tmux) return null + + // Get window width and all panes in the current window + const proc = spawn( + [ + tmux, + "list-panes", + "-t", + sourcePaneId, + "-F", + "#{pane_id},#{pane_width},#{pane_left},#{pane_title},#{pane_active},#{window_width}", + ], + { stdout: "pipe", stderr: "pipe" } + ) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + + if (exitCode !== 0) { + log("[pane-state-querier] list-panes failed", { exitCode }) + return null + } + + const lines = stdout.trim().split("\n").filter(Boolean) + if (lines.length === 0) return null + + let windowWidth = 0 + const panes: TmuxPaneInfo[] = [] + + for (const line of lines) { + const [paneId, widthStr, leftStr, title, activeStr, windowWidthStr] = line.split(",") + const width = parseInt(widthStr, 10) + const left = parseInt(leftStr, 10) + const isActive = activeStr === "1" + windowWidth = parseInt(windowWidthStr, 10) + + if (!isNaN(width) && !isNaN(left)) { + panes.push({ paneId, width, left, title, isActive }) + } + } + + // Sort panes by left position (leftmost first) + panes.sort((a, b) => a.left - b.left) + + // The main pane is the leftmost pane (where opencode runs) + // Agent panes are all other panes to the right + const mainPane = panes.find((p) => p.paneId === sourcePaneId) ?? panes[0] ?? null + const agentPanes = panes.filter((p) => p.paneId !== mainPane?.paneId) + + log("[pane-state-querier] window state", { + windowWidth, + mainPane: mainPane?.paneId, + agentPaneCount: agentPanes.length, + }) + + return { windowWidth, mainPane, agentPanes } +} diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index cc6f1864..b07a9f5a 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -5,3 +5,49 @@ export interface TrackedSession { createdAt: Date lastSeenAt: Date } + +/** + * Raw pane info from tmux list-panes command + * Source of truth - queried directly from tmux + */ +export interface TmuxPaneInfo { + paneId: string + width: number + left: number + title: string + isActive: boolean +} + +/** + * Current window state queried from tmux + * This is THE source of truth, not our internal Map + */ +export interface WindowState { + windowWidth: number + mainPane: TmuxPaneInfo | null + agentPanes: TmuxPaneInfo[] +} + +/** + * Actions that can be executed on tmux panes + */ +export type PaneAction = + | { type: "close"; paneId: string; sessionId: string } + | { type: "spawn"; sessionId: string; description: string; targetPaneId: string } + +/** + * Decision result from the decision engine + */ +export interface SpawnDecision { + canSpawn: boolean + actions: PaneAction[] + reason?: string +} + +/** + * Config needed for capacity calculation + */ +export interface CapacityConfig { + mainPaneMinWidth: number + agentPaneWidth: number +} diff --git a/src/index.ts b/src/index.ts index 95c9cfae..4b3d3fa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { enabled: pluginConfig.tmux?.enabled ?? false, layout: pluginConfig.tmux?.layout ?? 'main-vertical', main_pane_size: pluginConfig.tmux?.main_pane_size ?? 60, + main_pane_min_width: pluginConfig.tmux?.main_pane_min_width ?? 120, + agent_pane_min_width: pluginConfig.tmux?.agent_pane_min_width ?? 40, } as const; const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); @@ -225,10 +227,30 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const taskResumeInfo = createTaskResumeInfoHook(); - const backgroundManager = new BackgroundManager(ctx, pluginConfig.background_task); - const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig); + const backgroundManager = new BackgroundManager(ctx, pluginConfig.background_task, { + tmuxConfig, + onSubagentSessionCreated: async (event) => { + log("[index] onSubagentSessionCreated callback received", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }); + await tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }); + log("[index] onSubagentSessionCreated callback completed"); + }, + }); + const atlasHook = isHookEnabled("atlas") ? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager }) : null; @@ -266,6 +288,23 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { gitMasterConfig: pluginConfig.git_master, sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, browserProvider, + onSyncSessionCreated: async (event) => { + log("[index] onSyncSessionCreated callback", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }); + await tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }); + }, }); const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); const systemMcpNames = getSystemMcpServerNames(); @@ -451,17 +490,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const sessionInfo = props?.info as | { id?: string; title?: string; parentID?: string } | undefined; + log("[event] session.created", { sessionInfo, props }); if (!sessionInfo?.parentID) { setMainSession(sessionInfo?.id); } firstMessageVariantGate.markSessionCreated(sessionInfo); - if (sessionInfo?.id && sessionInfo?.title) { - await tmuxSessionManager.onSessionCreated({ - sessionID: sessionInfo.id, - parentID: sessionInfo.parentID, - title: sessionInfo.title, - }); - } + await tmuxSessionManager.onSessionCreated( + event as { type: string; properties?: { info?: { id?: string; parentID?: string; title?: string } } } + ); } if (event.type === "session.deleted") { diff --git a/src/shared/tmux/constants.ts b/src/shared/tmux/constants.ts index ae11f634..5299d396 100644 --- a/src/shared/tmux/constants.ts +++ b/src/shared/tmux/constants.ts @@ -7,5 +7,6 @@ export const SESSION_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes // Grace period for missing session before cleanup export const SESSION_MISSING_GRACE_MS = 6000 // 6 seconds -// Delay after pane spawn before sending prompt -export const PANE_SPAWN_DELAY_MS = 500 +// Session readiness polling config +export const SESSION_READY_POLL_INTERVAL_MS = 500 +export const SESSION_READY_TIMEOUT_MS = 10_000 // 10 seconds max wait diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index d32b8c6b..f7c02925 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -51,28 +51,82 @@ export function resetServerCheck(): void { serverCheckUrl = null } +export type SplitDirection = "-h" | "-v" + +export function getCurrentPaneId(): string | undefined { + return process.env.TMUX_PANE +} + +export interface PaneDimensions { + paneWidth: number + windowWidth: number +} + +export async function getPaneDimensions(paneId: string): Promise { + const tmux = await getTmuxPath() + if (!tmux) return null + + const proc = spawn([tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + + if (exitCode !== 0) return null + + const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number) + if (isNaN(paneWidth) || isNaN(windowWidth)) return null + + return { paneWidth, windowWidth } +} + export async function spawnTmuxPane( sessionId: string, description: string, config: TmuxConfig, - serverUrl: string + serverUrl: string, + targetPaneId?: string, + splitDirection: SplitDirection = "-h" ): Promise { - if (!config.enabled) return { success: false } - if (!isInsideTmux()) return { success: false } - if (!(await isServerRunning(serverUrl))) return { success: false } + const { log } = await import("../logger") + + log("[spawnTmuxPane] called", { sessionId, description, serverUrl, configEnabled: config.enabled, targetPaneId, splitDirection }) + + if (!config.enabled) { + log("[spawnTmuxPane] SKIP: config.enabled is false") + return { success: false } + } + if (!isInsideTmux()) { + log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX }) + return { success: false } + } + + const serverRunning = await isServerRunning(serverUrl) + if (!serverRunning) { + log("[spawnTmuxPane] SKIP: server not running", { serverUrl }) + return { success: false } + } const tmux = await getTmuxPath() - if (!tmux) return { success: false } + if (!tmux) { + log("[spawnTmuxPane] SKIP: tmux not found") + return { success: false } + } + + log("[spawnTmuxPane] all checks passed, spawning...") const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` const args = [ "split-window", - "-h", + splitDirection, "-d", "-P", "-F", "#{pane_id}", + "-l", String(config.agent_pane_min_width), + ...(targetPaneId ? ["-t", targetPaneId] : []), opencodeCmd, ] @@ -91,22 +145,37 @@ export async function spawnTmuxPane( stderr: "ignore", }) - await applyLayout(tmux, config.layout, config.main_pane_size) - return { success: true, paneId } } export async function closeTmuxPane(paneId: string): Promise { - if (!isInsideTmux()) return false + const { log } = await import("../logger") + + if (!isInsideTmux()) { + log("[closeTmuxPane] SKIP: not inside tmux") + return false + } const tmux = await getTmuxPath() - if (!tmux) return false + if (!tmux) { + log("[closeTmuxPane] SKIP: tmux not found") + return false + } + log("[closeTmuxPane] killing pane", { paneId }) + const proc = spawn([tmux, "kill-pane", "-t", paneId], { - stdout: "ignore", - stderr: "ignore", + stdout: "pipe", + stderr: "pipe", }) const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + } else { + log("[closeTmuxPane] SUCCESS", { paneId }) + } return exitCode === 0 } diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index ffabede8..cc683896 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -151,6 +151,12 @@ export function resolveCategoryConfig( return { config, promptAppend, model } } +export interface SyncSessionCreatedEvent { + sessionID: string + parentID: string + title: string +} + export interface DelegateTaskToolOptions { manager: BackgroundManager client: OpencodeClient @@ -159,6 +165,7 @@ export interface DelegateTaskToolOptions { gitMasterConfig?: GitMasterConfig sisyphusJuniorModel?: string browserProvider?: BrowserAutomationProvider + onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise } export interface BuildSystemContentInput { @@ -181,7 +188,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und } export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition { - const { manager, client, directory, userCategories, gitMasterConfig, sisyphusJuniorModel, browserProvider } = options + const { manager, client, directory, userCategories, gitMasterConfig, sisyphusJuniorModel, browserProvider, onSyncSessionCreated } = options const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } const categoryNames = Object.keys(allCategories) @@ -850,6 +857,18 @@ To continue this session: session_id="${task.sessionID}"` syncSessionID = sessionID subagentSessions.add(sessionID) + if (onSyncSessionCreated) { + log("[delegate_task] Invoking onSyncSessionCreated callback", { sessionID, parentID: ctx.sessionID }) + await onSyncSessionCreated({ + sessionID, + parentID: ctx.sessionID, + title: args.description, + }).catch((err) => { + log("[delegate_task] onSyncSessionCreated callback failed", { error: String(err) }) + }) + await new Promise(r => setTimeout(r, 200)) + } + taskId = `sync_${sessionID.slice(0, 8)}` const startTime = new Date()