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 <clio-agent@sisyphuslabs.ai>

* 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 <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
justsisyphus
2026-01-26 12:02:37 +09:00
committed by GitHub
parent 3a79b8761b
commit 68aa913499
15 changed files with 1390 additions and 255 deletions

View File

@@ -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
}
}
}

View File

@@ -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(),

View File

@@ -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)
})
})

View File

@@ -55,6 +55,14 @@ interface QueueItem {
input: LaunchInput
}
export interface SubagentSessionCreatedEvent {
sessionID: string
parentID: string
title: string
}
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
export class BackgroundManager {
private static cleanupManagers = new Set<BackgroundManager>()
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<string, QueueItem[]> = new Map()
private processingKeys: Set<string> = 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

View File

@@ -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<ActionResult> {
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<ExecuteActionsResult> {
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 }
}

View File

@@ -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 }
}

View File

@@ -1,2 +1,5 @@
export * from "./manager"
export * from "./types"
export * from "./pane-state-querier"
export * from "./decision-engine"
export * from "./action-executor"

View File

@@ -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<WindowState | null>>(
async () => ({
windowWidth: 200,
mainPane: { paneId: '%0', width: 120, left: 0, title: 'main', isActive: true },
agentPanes: [],
})
)
const mockPaneExists = mock<(paneId: string) => Promise<boolean>>(async () => true)
const mockExecuteActions = mock<(
actions: PaneAction[],
config: TmuxConfig,
serverUrl: string
) => Promise<ExecuteActionsResult>>(async () => ({
success: true,
spawnedPaneId: '%mock',
results: [],
}))
const mockExecuteAction = mock<(
action: PaneAction,
config: TmuxConfig,
serverUrl: string
) => Promise<ActionResult>>(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<string>()
function createMockContext(overrides?: {
sessionStatusResult?: { data?: Record<string, { type: string }> }
}) {
@@ -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<string, { type: string }> = {}
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>): 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')
})
})
})

View File

@@ -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<string, TrackedSession>
private client: OpencodeClient
private tmuxConfig: TmuxConfig
private serverUrl: string
private config: TmuxConfig
private ctx: PluginInput
private pollingInterval: ReturnType<typeof setInterval> | null = null
private sourcePaneId: string | undefined
private sessions = new Map<string, TrackedSession>()
private pendingSessions = new Set<string>()
private pollInterval?: ReturnType<typeof setInterval>
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<void> {
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<boolean> {
const startTime = Date.now()
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
try {
const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
if (allStatuses[sessionId]) {
log("[tmux-session-manager] session ready", {
sessionId,
status: allStatuses[sessionId].type,
waitedMs: Date.now() - startTime,
})
return true
}
} catch (err) {
log("[tmux-session-manager] session status check error", { error: String(err) })
}
await new Promise((resolve) => setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS))
}
log("[tmux-session-manager] session ready timeout", {
sessionId,
timeoutMs: SESSION_READY_TIMEOUT_MS,
})
return false
}
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<void> {
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<void> {
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<void> {
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<string, { type: string }>
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<void> {
const tracked = this.sessions.get(sessionId)
if (!tracked) return
await closeTmuxPane(tracked.paneId)
this.sessions.delete(sessionId)
}
async cleanup(): Promise<void> {
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<void> {
if (this.sessions.size === 0) {
this.stopPolling()
return
}
try {
const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()),
allStatusKeys: Object.keys(allStatuses),
})
}, 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<void> {
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<void> {
return async (input) => {
await this.onSessionCreated(input.event as SessionCreatedEvent)
}
}
async cleanup(): Promise<void> {
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")
}
}

View File

@@ -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<WindowState | null> {
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 }
}

View File

@@ -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
}

View File

@@ -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") {

View File

@@ -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

View File

@@ -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<PaneDimensions | null> {
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<SpawnPaneResult> {
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<boolean> {
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
}

View File

@@ -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<void>
}
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()