From aead4aebd28abf0be5e17f075d66eb76faacf083 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 25 Jan 2026 15:34:10 +0900 Subject: [PATCH] Add tmux pane management for background agent sessions (#1094) * feat(config): add TmuxConfigSchema for tmux subagent pane management Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * feat(shared): add tmux module structure * feat(shared/tmux): implement tmux pane utilities Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * test(tmux-subagent): add TmuxSessionManager tests (TDD RED) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * feat(tmux-subagent): implement TmuxSessionManager Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * feat(integration): wire TmuxSessionManager with 500ms delay - Task 5: Add 500ms delay in BackgroundManager after session creation - Task 6: Wire TmuxSessionManager event handlers (session.created/deleted) - Both changes integrate tmux pane management into plugin lifecycle Co-authored-by: Sisyphus --------- Co-authored-by: justsisyphus Co-authored-by: Sisyphus Co-authored-by: Sisyphus --- assets/oh-my-opencode.schema.json | 26 ++ bun.lock | 28 +- src/config/index.ts | 4 + src/config/schema.ts | 16 ++ src/features/background-agent/manager.ts | 17 +- src/features/tmux-subagent/index.ts | 2 + src/features/tmux-subagent/manager.test.ts | 299 +++++++++++++++++++++ src/features/tmux-subagent/manager.ts | 127 +++++++++ src/features/tmux-subagent/types.ts | 7 + src/index.ts | 63 +++-- src/shared/index.ts | 1 + src/shared/tmux/constants.ts | 11 + src/shared/tmux/index.ts | 3 + src/shared/tmux/tmux-utils.test.ts | 195 ++++++++++++++ src/shared/tmux/tmux-utils.ts | 129 +++++++++ src/shared/tmux/types.ts | 4 + 16 files changed, 893 insertions(+), 39 deletions(-) create mode 100644 src/features/tmux-subagent/index.ts create mode 100644 src/features/tmux-subagent/manager.test.ts create mode 100644 src/features/tmux-subagent/manager.ts create mode 100644 src/features/tmux-subagent/types.ts create mode 100644 src/shared/tmux/constants.ts create mode 100644 src/shared/tmux/index.ts create mode 100644 src/shared/tmux/tmux-utils.test.ts create mode 100644 src/shared/tmux/tmux-utils.ts create mode 100644 src/shared/tmux/types.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 126fcc1e6..0a29a9d5e 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2186,6 +2186,32 @@ ] } } + }, + "tmux": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "layout": { + "default": "main-vertical", + "type": "string", + "enum": [ + "main-horizontal", + "main-vertical", + "tiled", + "even-horizontal", + "even-vertical" + ] + }, + "main_pane_size": { + "default": 60, + "type": "number", + "minimum": 20, + "maximum": 80 + } + } } } } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 30355f0a2..e0aeb1db4 100644 --- a/bun.lock +++ b/bun.lock @@ -27,13 +27,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.0.0", - "oh-my-opencode-darwin-x64": "3.0.0", - "oh-my-opencode-linux-arm64": "3.0.0", - "oh-my-opencode-linux-arm64-musl": "3.0.0", - "oh-my-opencode-linux-x64": "3.0.0", - "oh-my-opencode-linux-x64-musl": "3.0.0", - "oh-my-opencode-windows-x64": "3.0.0", + "oh-my-opencode-darwin-arm64": "3.0.1", + "oh-my-opencode-darwin-x64": "3.0.1", + "oh-my-opencode-linux-arm64": "3.0.1", + "oh-my-opencode-linux-arm64-musl": "3.0.1", + "oh-my-opencode-linux-x64": "3.0.1", + "oh-my-opencode-linux-x64-musl": "3.0.1", + "oh-my-opencode-windows-x64": "3.0.1", }, }, }, @@ -225,19 +225,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zelvb7qz5GsS+Dhyz9rACZrkUMtWbAZGijiHSQqmRcjlN/sRPNhXtsL55VheDjlPM3VP+t3+psv+se0WA/aw5w=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-LRcLVi6DsmGh3ICFeN4yVJ0KinvCM5jotd2z7tZQ74n0sziHO7grjK1CmJaPV9eCv0clatoK5xfFCeEJ3FvXYg=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-dRMD1U5zIrb6BsiKQJZtAFtuD8clAQquZyU2LajMoFTHBNhcBDIgsaBBwvMBIq7dTe8rnFq91ExiFA8OfdrzBA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ZaC0ZBe5M2f2aMncNsAMu9IZ3MjSPfNVcfUTCgJkp03db8lLPsajgjeG3556Er72hxignDPsEbrLkJBNlsDbAA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Wx6Cx2Nu2T69mfZa3FQ3gk0OFONvMh48rMVYK0Cp8VX5W4Zb/GZgTUFmZlYsApyxqP+7J9m18skd46qPOhzuEQ=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pcOvV6Y2GSwKr0exDndeB2BtFt297XhJFQgrq1cbeEJawoRONDRp7LNSpjwILSQpQ7YkkYnO2bIczBmxI5llNA=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mfOlptgLoXLVuhFRcXgZU7BYGuL1axZOMOOjONgncNzOp/BQYU5B9BRFihBUXdDsWGmeMiLowrYGBhVpSv3NlA=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7kXKaVbgFnOMSaw+j4JbZNs7O7mkvCekcfWPwh/9I/0WD21/n4PbAGl01ePhRoQh+u9MC6t8FH046hEjL2sk1g=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vVjshfaz0UC9NrGD9FfjlYK5NvckIW0sZaE/wRv/LKjrukHFH1jJpJa5KKXxBWLsEJjt6ooJRguXXxtfNXpAWw=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1BOV1EnKa5BErhZmWiddnbriHwm1KFrPr+0BUCDdFX/d/hrMAJTo1733zaEnvKuXzvrdHSp/VznXheeUI1VjkA=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6cNJ7+Dj0a5dWqPf6OKfB39o8HWw5HQ3hB4omgYqc6Gzo6nChA4KIiVefEC3+tIL98x4XvMeD7OU+UYgwxHnQ=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ASyTVatvU1nNJ0mk9o+A/GjybT5vOdgU172ystzCsnQ+12Mnv68GgaeMu/UFJgJNaZmKdhyUAP9XhnOKvEDBGQ=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-TaC0hiHpnsS42GWTVUKoTwCb+QzNLBlQtTkIQ0PjlkDYFjlEC2LuR2FFcscik055PRRIGishyB9A1n/8XAgcvA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-QIuA564mVpwzCprhhAoyd8TSw0Rt2VM6M9y7H0fOoC/UjXuU+d7wIuUNuqUUMVaUnMedkctTZop0X0i2Q+Bvhg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/config/index.ts b/src/config/index.ts index fb0f98c8d..88bfd765f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,8 @@ export { SisyphusAgentConfigSchema, ExperimentalConfigSchema, RalphLoopConfigSchema, + TmuxConfigSchema, + TmuxLayoutSchema, } from "./schema" export type { @@ -23,4 +25,6 @@ export type { ExperimentalConfig, DynamicContextPruningConfig, RalphLoopConfig, + TmuxConfig, + TmuxLayout, } from "./schema" diff --git a/src/config/schema.ts b/src/config/schema.ts index 44d7f2d80..1d9214195 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -310,6 +310,19 @@ export const BrowserAutomationConfigSchema = z.object({ provider: BrowserAutomationProviderSchema.default("playwright"), }) +export const TmuxLayoutSchema = z.enum([ + 'main-horizontal', // main pane top, agent panes bottom stack + 'main-vertical', // main pane left, agent panes right stack (default) + 'tiled', // all panes same size grid + 'even-horizontal', // all panes horizontal row + 'even-vertical', // all panes vertical stack +]) + +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% +}) export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), disabled_mcps: z.array(AnyMcpNameSchema).optional(), @@ -330,6 +343,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ notification: NotificationConfigSchema.optional(), git_master: GitMasterConfigSchema.optional(), browser_automation_engine: BrowserAutomationConfigSchema.optional(), + tmux: TmuxConfigSchema.optional(), }) export type OhMyOpenCodeConfig = z.infer @@ -354,5 +368,7 @@ export type BuiltinCategoryName = z.infer export type GitMasterConfig = z.infer export type BrowserAutomationProvider = z.infer export type BrowserAutomationConfig = z.infer +export type TmuxConfig = z.infer +export type TmuxLayout = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 0b2a51aa8..a8dbc9454 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -7,7 +7,8 @@ import type { } from "./types" import { log, getAgentToolRestrictions } from "../../shared" import { ConcurrencyManager } from "./concurrency" -import type { BackgroundTaskConfig } from "../../config/schema" +import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" +import { isInsideTmux } from "../../shared/tmux" import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" @@ -68,12 +69,16 @@ export class BackgroundManager { private concurrencyManager: ConcurrencyManager private shutdownTriggered = false private config?: BackgroundTaskConfig - + private tmuxEnabled: boolean private queuesByKey: Map = new Map() private processingKeys: Set = new Set() - constructor(ctx: PluginInput, config?: BackgroundTaskConfig) { + constructor( + ctx: PluginInput, + config?: BackgroundTaskConfig, + tmuxConfig?: TmuxConfig + ) { this.tasks = new Map() this.notifications = new Map() this.pendingByParent = new Map() @@ -81,6 +86,7 @@ export class BackgroundManager { this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) this.config = config + this.tmuxEnabled = tmuxConfig?.enabled ?? false this.registerProcessCleanup() } @@ -222,6 +228,11 @@ 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)) + } + // Update task to running state task.status = "running" task.startedAt = new Date() diff --git a/src/features/tmux-subagent/index.ts b/src/features/tmux-subagent/index.ts new file mode 100644 index 000000000..72836125f --- /dev/null +++ b/src/features/tmux-subagent/index.ts @@ -0,0 +1,2 @@ +export * from "./manager" +export * from "./types" diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts new file mode 100644 index 000000000..758ad870e --- /dev/null +++ b/src/features/tmux-subagent/manager.test.ts @@ -0,0 +1,299 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test' +import type { TmuxConfig } from '../../config/schema' + +// Mock setup - tmux-utils functions +const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: '%mock' })) +const mockCloseTmuxPane = mock(async () => true) +const mockIsInsideTmux = mock(() => true) + +mock.module('../../shared/tmux', () => ({ + spawnTmuxPane: mockSpawnTmuxPane, + closeTmuxPane: mockCloseTmuxPane, + isInsideTmux: mockIsInsideTmux, + POLL_INTERVAL_BACKGROUND_MS: 2000, + SESSION_TIMEOUT_MS: 600000, + SESSION_MISSING_GRACE_MS: 6000, +})) + +// Mock context helper +function createMockContext(overrides?: { + sessionStatusResult?: { data?: Record } +}) { + return { + serverUrl: new URL('http://localhost:4096'), + client: { + session: { + status: mock(async () => overrides?.sessionStatusResult ?? { data: {} }), + }, + }, + } as any +} + +describe('TmuxSessionManager', () => { + beforeEach(() => { + // Reset mocks before each test + mockSpawnTmuxPane.mockClear() + mockCloseTmuxPane.mockClear() + mockIsInsideTmux.mockClear() + }) + + describe('constructor', () => { + test('enabled when config.enabled=true and isInsideTmux=true', 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, + } + + // #when + const manager = new TmuxSessionManager(ctx, config) + + // #then + expect(manager).toBeDefined() + }) + + test('disabled when config.enabled=true but isInsideTmux=false', async () => { + // #given + mockIsInsideTmux.mockReturnValue(false) + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: true, + layout: 'main-vertical', + main_pane_size: 60, + } + + // #when + const manager = new TmuxSessionManager(ctx, config) + + // #then + expect(manager).toBeDefined() + }) + + test('disabled when config.enabled=false', async () => { + // #given + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: false, + layout: 'main-vertical', + main_pane_size: 60, + } + + // #when + const manager = new TmuxSessionManager(ctx, config) + + // #then + expect(manager).toBeDefined() + }) + }) + + describe('onSessionCreated', () => { + test('spawns pane when session has parentID', 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, + } + 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' + ) + }) + + test('does NOT spawn pane when session has no parentID', 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, + } + const manager = new TmuxSessionManager(ctx, config) + + const event = { + sessionID: 'ses_root', + parentID: undefined, + title: 'Root Session', + } + + // #when + await manager.onSessionCreated(event) + + // #then + expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0) + }) + + test('does NOT spawn pane when disabled', async () => { + // #given + mockIsInsideTmux.mockReturnValue(true) + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext() + const config: TmuxConfig = { + enabled: false, + layout: 'main-vertical', + main_pane_size: 60, + } + 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(0) + }) + }) + + describe('onSessionDeleted', () => { + test('closes pane when tracked session is deleted', 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, + } + 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', + }) + + // #when + await manager.onSessionDeleted({ sessionID: 'ses_child' }) + + // #then + expect(mockCloseTmuxPane).toHaveBeenCalledTimes(1) + }) + + test('does nothing when untracked session is deleted', 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, + } + const manager = new TmuxSessionManager(ctx, config) + + // #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) + }) + }) + + describe('cleanup', () => { + test('closes all tracked panes', 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, + } + 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', + }) + + mockCloseTmuxPane.mockClear() + + // #when + await manager.cleanup() + + // #then + expect(mockCloseTmuxPane).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts new file mode 100644 index 000000000..f6e72db46 --- /dev/null +++ b/src/features/tmux-subagent/manager.ts @@ -0,0 +1,127 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import { + spawnTmuxPane, + closeTmuxPane, + isInsideTmux, + POLL_INTERVAL_BACKGROUND_MS, + SESSION_MISSING_GRACE_MS, +} from "../../shared/tmux" + +export class TmuxSessionManager { + private enabled: boolean + private sessions: Map + private serverUrl: string + private config: TmuxConfig + private ctx: PluginInput + private pollingInterval: ReturnType | null = null + + constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) { + this.ctx = ctx + this.config = tmuxConfig + this.sessions = new Map() + + this.enabled = tmuxConfig.enabled && isInsideTmux() + + const defaultPort = process.env.OPENCODE_PORT ?? "4096" + const urlString = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` + this.serverUrl = urlString.endsWith("/") ? urlString.slice(0, -1) : urlString + + if (this.enabled) { + this.startPolling() + } + } + + async onSessionCreated(event: { + sessionID: string + parentID?: string + title: string + }): Promise { + if (!this.enabled) return + if (!event.parentID) return + + const result = await spawnTmuxPane( + event.sessionID, + event.title, + this.config, + this.serverUrl + ) + + 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 onSessionDeleted(event: { sessionID: string }): Promise { + if (!this.enabled) return + + const tracked = this.sessions.get(event.sessionID) + if (!tracked) return + + await this.closeSession(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 + } + + for (const sessionId of Array.from(this.sessions.keys())) { + await this.closeSession(sessionId) + } + } + + private startPolling(): void { + this.pollingInterval = setInterval(() => { + this.pollSessions().catch(() => { + // Ignore errors + }) + }, POLL_INTERVAL_BACKGROUND_MS) + } +} diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts new file mode 100644 index 000000000..cc6f1864f --- /dev/null +++ b/src/features/tmux-subagent/types.ts @@ -0,0 +1,7 @@ +export interface TrackedSession { + sessionId: string + paneId: string + description: string + createdAt: Date + lastSeenAt: Date +} diff --git a/src/index.ts b/src/index.ts index b05876e9e..475f62207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,7 @@ import { import { BackgroundManager } from "./features/background-agent"; import { SkillMcpManager } from "./features/skill-mcp-manager"; import { initTaskToastManager } from "./features/task-toast-manager"; +import { TmuxSessionManager } from "./features/tmux-subagent"; import { type HookName } from "./config"; import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared"; import { loadPluginConfig } from "./plugin-config"; @@ -88,6 +89,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const pluginConfig = loadPluginConfig(ctx.directory, ctx); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); const firstMessageVariantGate = createFirstMessageVariantGate(); + + const tmuxConfig = { + enabled: pluginConfig.tmux?.enabled ?? false, + layout: pluginConfig.tmux?.layout ?? 'main-vertical', + main_pane_size: pluginConfig.tmux?.main_pane_size ?? 60, + } as const; const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); const modelCacheState = createModelCacheState(); @@ -215,6 +222,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const backgroundManager = new BackgroundManager(ctx, pluginConfig.background_task); + const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig); + const atlasHook = isHookEnabled("atlas") ? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager }) : null; @@ -432,29 +441,39 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const { event } = input; const props = event.properties as Record | undefined; - if (event.type === "session.created") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - if (!sessionInfo?.parentID) { - setMainSession(sessionInfo?.id); - } - firstMessageVariantGate.markSessionCreated(sessionInfo); - } + if (event.type === "session.created") { + const sessionInfo = props?.info as + | { id?: string; title?: string; parentID?: string } + | undefined; + 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, + }); + } + } - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { - setMainSession(undefined); - } - if (sessionInfo?.id) { - clearSessionAgent(sessionInfo.id); - resetMessageCursor(sessionInfo.id); - firstMessageVariantGate.clear(sessionInfo.id); - await skillMcpManager.disconnectSession(sessionInfo.id); - await lspManager.cleanupTempDirectoryClients(); - } - } + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id === getMainSessionID()) { + setMainSession(undefined); + } + if (sessionInfo?.id) { + clearSessionAgent(sessionInfo.id); + resetMessageCursor(sessionInfo.id); + firstMessageVariantGate.clear(sessionInfo.id); + await skillMcpManager.disconnectSession(sessionInfo.id); + await lspManager.cleanupTempDirectoryClients(); + await tmuxSessionManager.onSessionDeleted({ + sessionID: sessionInfo.id, + }); + } + } if (event.type === "message.updated") { const info = props?.info as Record | undefined; diff --git a/src/shared/index.ts b/src/shared/index.ts index 6c681a01b..97b408228 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -30,3 +30,4 @@ export * from "./model-resolver" export * from "./model-availability" export * from "./case-insensitive" export * from "./session-utils" +export * from "./tmux" diff --git a/src/shared/tmux/constants.ts b/src/shared/tmux/constants.ts new file mode 100644 index 000000000..ae11f6347 --- /dev/null +++ b/src/shared/tmux/constants.ts @@ -0,0 +1,11 @@ +// Polling interval for background session status checks +export const POLL_INTERVAL_BACKGROUND_MS = 2000 + +// Maximum idle time before session considered stale +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 diff --git a/src/shared/tmux/index.ts b/src/shared/tmux/index.ts new file mode 100644 index 000000000..a86723661 --- /dev/null +++ b/src/shared/tmux/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./constants" +export * from "./tmux-utils" diff --git a/src/shared/tmux/tmux-utils.test.ts b/src/shared/tmux/tmux-utils.test.ts new file mode 100644 index 000000000..a753cf827 --- /dev/null +++ b/src/shared/tmux/tmux-utils.test.ts @@ -0,0 +1,195 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { + isInsideTmux, + isServerRunning, + resetServerCheck, + spawnTmuxPane, + closeTmuxPane, + applyLayout, +} from "./tmux-utils" + +describe("isInsideTmux", () => { + test("returns true when TMUX env is set", () => { + // #given + const originalTmux = process.env.TMUX + process.env.TMUX = "/tmp/tmux-1000/default" + + // #when + const result = isInsideTmux() + + // #then + expect(result).toBe(true) + + // cleanup + process.env.TMUX = originalTmux + }) + + test("returns false when TMUX env is not set", () => { + // #given + const originalTmux = process.env.TMUX + delete process.env.TMUX + + // #when + const result = isInsideTmux() + + // #then + expect(result).toBe(false) + + // cleanup + process.env.TMUX = originalTmux + }) + + test("returns false when TMUX env is empty string", () => { + // #given + const originalTmux = process.env.TMUX + process.env.TMUX = "" + + // #when + const result = isInsideTmux() + + // #then + expect(result).toBe(false) + + // cleanup + process.env.TMUX = originalTmux + }) +}) + +describe("isServerRunning", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + resetServerCheck() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("returns true when server responds OK", async () => { + // #given + globalThis.fetch = mock(async () => ({ ok: true })) as any + + // #when + const result = await isServerRunning("http://localhost:4096") + + // #then + expect(result).toBe(true) + }) + + test("returns false when server not reachable", async () => { + // #given + globalThis.fetch = mock(async () => { + throw new Error("ECONNREFUSED") + }) as any + + // #when + const result = await isServerRunning("http://localhost:4096") + + // #then + expect(result).toBe(false) + }) + + test("returns false when fetch returns not ok", async () => { + // #given + globalThis.fetch = mock(async () => ({ ok: false })) as any + + // #when + const result = await isServerRunning("http://localhost:4096") + + // #then + expect(result).toBe(false) + }) + + test("caches successful result", async () => { + // #given + const fetchMock = mock(async () => ({ ok: true })) as any + globalThis.fetch = fetchMock + + // #when + await isServerRunning("http://localhost:4096") + await isServerRunning("http://localhost:4096") + + // #then - should only call fetch once due to caching + expect(fetchMock.mock.calls.length).toBe(1) + }) + + test("does not cache failed result", async () => { + // #given + const fetchMock = mock(async () => { + throw new Error("ECONNREFUSED") + }) as any + globalThis.fetch = fetchMock + + // #when + await isServerRunning("http://localhost:4096") + await isServerRunning("http://localhost:4096") + + // #then - should call fetch 4 times (2 attempts per call, 2 calls) + expect(fetchMock.mock.calls.length).toBe(4) + }) + + test("uses different cache for different URLs", async () => { + // #given + const fetchMock = mock(async () => ({ ok: true })) as any + globalThis.fetch = fetchMock + + // #when + await isServerRunning("http://localhost:4096") + await isServerRunning("http://localhost:5000") + + // #then - should call fetch twice for different URLs + expect(fetchMock.mock.calls.length).toBe(2) + }) +}) + +describe("resetServerCheck", () => { + test("clears cache without throwing", () => { + // #given, #when, #then + expect(() => resetServerCheck()).not.toThrow() + }) + + test("allows re-checking after reset", async () => { + // #given + const originalFetch = globalThis.fetch + const fetchMock = mock(async () => ({ ok: true })) as any + globalThis.fetch = fetchMock + + // #when + await isServerRunning("http://localhost:4096") + resetServerCheck() + await isServerRunning("http://localhost:4096") + + // #then - should call fetch twice after reset + expect(fetchMock.mock.calls.length).toBe(2) + + // cleanup + globalThis.fetch = originalFetch + }) +}) + +describe("tmux pane functions", () => { + test("spawnTmuxPane is exported as function", async () => { + // #given, #when + const result = typeof spawnTmuxPane + + // #then + expect(result).toBe("function") + }) + + test("closeTmuxPane is exported as function", async () => { + // #given, #when + const result = typeof closeTmuxPane + + // #then + expect(result).toBe("function") + }) + + test("applyLayout is exported as function", async () => { + // #given, #when + const result = typeof applyLayout + + // #then + expect(result).toBe("function") + }) +}) diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts new file mode 100644 index 000000000..d32b8c6b5 --- /dev/null +++ b/src/shared/tmux/tmux-utils.ts @@ -0,0 +1,129 @@ +import { spawn } from "bun" +import type { TmuxConfig, TmuxLayout } from "../../config/schema" +import type { SpawnPaneResult } from "./types" +import { getTmuxPath } from "../../tools/interactive-bash/utils" + +let serverAvailable: boolean | null = null +let serverCheckUrl: string | null = null + +export function isInsideTmux(): boolean { + return !!process.env.TMUX +} + +export async function isServerRunning(serverUrl: string): Promise { + if (serverCheckUrl === serverUrl && serverAvailable === true) { + return true + } + + const healthUrl = new URL("/health", serverUrl).toString() + const timeoutMs = 3000 + const maxAttempts = 2 + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(healthUrl, { signal: controller.signal }).catch( + () => null + ) + clearTimeout(timeout) + + if (response?.ok) { + serverCheckUrl = serverUrl + serverAvailable = true + return true + } + } finally { + clearTimeout(timeout) + } + + if (attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, 250)) + } + } + + return false +} + +export function resetServerCheck(): void { + serverAvailable = null + serverCheckUrl = null +} + +export async function spawnTmuxPane( + sessionId: string, + description: string, + config: TmuxConfig, + serverUrl: string +): Promise { + if (!config.enabled) return { success: false } + if (!isInsideTmux()) return { success: false } + if (!(await isServerRunning(serverUrl))) return { success: false } + + const tmux = await getTmuxPath() + if (!tmux) return { success: false } + + const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` + + const args = [ + "split-window", + "-h", + "-d", + "-P", + "-F", + "#{pane_id}", + opencodeCmd, + ] + + const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const paneId = stdout.trim() + + if (exitCode !== 0 || !paneId) { + return { success: false } + } + + const title = `omo-subagent-${description.slice(0, 20)}` + spawn([tmux, "select-pane", "-t", paneId, "-T", title], { + stdout: "ignore", + 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 tmux = await getTmuxPath() + if (!tmux) return false + + const proc = spawn([tmux, "kill-pane", "-t", paneId], { + stdout: "ignore", + stderr: "ignore", + }) + const exitCode = await proc.exited + + return exitCode === 0 +} + +export async function applyLayout( + tmux: string, + layout: TmuxLayout, + mainPaneSize: number +): Promise { + spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" }) + + if (layout.startsWith("main-")) { + const dimension = + layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" + spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], { + stdout: "ignore", + stderr: "ignore", + }) + } +} diff --git a/src/shared/tmux/types.ts b/src/shared/tmux/types.ts new file mode 100644 index 000000000..b3d9f724f --- /dev/null +++ b/src/shared/tmux/types.ts @@ -0,0 +1,4 @@ +export interface SpawnPaneResult { + success: boolean + paneId?: string // e.g., "%42" +}