Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions assets/oh-my-opencode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"interactive-bash-session",
"thinking-block-validator",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command",
Expand Down Expand Up @@ -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
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
14 changes: 7 additions & 7 deletions src/features/background-agent/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,15 +776,15 @@ 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" },
}

// #when
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" })
})

Expand Down Expand Up @@ -827,19 +827,19 @@ 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" },
}

// #when
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)
})

Expand All @@ -856,15 +856,15 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "Sisyphus",
parentAgent: "sisyphus",
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
}

// #when
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)
})
})
Expand Down
41 changes: 36 additions & 5 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -70,14 +78,18 @@ 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()

constructor(
ctx: PluginInput,
config?: BackgroundTaskConfig,
tmuxConfig?: TmuxConfig
options?: {
tmuxConfig?: TmuxConfig
onSubagentSessionCreated?: OnSubagentSessionCreated
}
) {
this.tasks = new Map()
this.notifications = new Map()
Expand All @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions src/features/tmux-subagent/action-executor.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 130 additions & 0 deletions src/features/tmux-subagent/decision-engine.ts
Original file line number Diff line number Diff line change
@@ -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)
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Filter cached mappings by panes that actually exist in the queried window state before choosing the oldest to close; otherwise a stale mapping can cause a close failure and prevent spawning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/tmux-subagent/decision-engine.ts, line 77:

<comment>Filter cached mappings by panes that actually exist in the queried window state before choosing the oldest to close; otherwise a stale mapping can cause a close failure and prevent spawning.</comment>

<file context>
@@ -0,0 +1,130 @@
+  }
+
+  if (state.agentPanes.length > 0) {
+    const oldest = findOldestSession(sessionMappings)
+    
+    if (oldest) {
</file context>
Fix with Cubic


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 }
}
3 changes: 3 additions & 0 deletions src/features/tmux-subagent/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export * from "./manager"
export * from "./types"
export * from "./pane-state-querier"
export * from "./decision-engine"
export * from "./action-executor"
Loading