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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
66
src/features/tmux-subagent/action-executor.ts
Normal file
66
src/features/tmux-subagent/action-executor.ts
Normal 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 }
|
||||
}
|
||||
130
src/features/tmux-subagent/decision-engine.ts
Normal file
130
src/features/tmux-subagent/decision-engine.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./manager"
|
||||
export * from "./types"
|
||||
export * from "./pane-state-querier"
|
||||
export * from "./decision-engine"
|
||||
export * from "./action-executor"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
68
src/features/tmux-subagent/pane-state-querier.ts
Normal file
68
src/features/tmux-subagent/pane-state-querier.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
54
src/index.ts
54
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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user