fix(tmux): pass --dir to opencode attach in subagent spawn helpers

User-visible symptom: tmux subagent panes "open then immediately close" even after wait-before-attach + attachable-status + session.idle retry fixes. Team-layout panes do not exhibit this because they already pass --dir.

Root cause: pane-spawn.ts / window-spawn.ts / session-spawn.ts / pane-replace.ts construct `opencode attach <url> --session <id>` without --dir. opencode attach inherits the tmux pane's cwd rather than the session's directory, which can cause the TUI to exit at startup and the pane to die. Team layout has always passed --dir and works correctly.

This commit threads the subagent manager's working directory through the four spawn helpers, bringing them in line with the team-layout contract. Attach now receives the correct directory for every subagent pane.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-20 18:05:56 +09:00
parent c2f03f1341
commit 74c1942af5
12 changed files with 243 additions and 74 deletions

View File

@@ -10,6 +10,7 @@ export interface ActionResult {
export interface ExecuteContext {
config: TmuxConfig
directory: string
serverUrl: string
windowState: WindowState
}
@@ -55,6 +56,7 @@ export async function executeActionWithDeps(
action.description,
ctx.config,
ctx.serverUrl,
ctx.directory,
)
return {
success: result.success,
@@ -67,6 +69,7 @@ export async function executeActionWithDeps(
action.description,
ctx.config,
ctx.serverUrl,
ctx.directory,
action.targetPaneId,
action.splitDirection,
)

View File

@@ -4,7 +4,9 @@ import { executeActionWithDeps } from "./action-executor-core"
import type { ActionExecutorDeps, ExecuteContext } from "./action-executor-core"
import type { WindowState } from "./types"
const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: "%7" }))
type SpawnPaneResult = Awaited<ReturnType<ActionExecutorDeps["spawnTmuxPane"]>>
const mockSpawnTmuxPane = mock(async (): Promise<SpawnPaneResult> => ({ success: true, paneId: "%7" }))
const mockCloseTmuxPane = mock(async () => true)
const mockEnforceMainPaneWidth = mock(async () => undefined)
const mockReplaceTmuxPane = mock(async () => ({ success: true, paneId: "%7" }))
@@ -21,6 +23,7 @@ const mockDeps: ActionExecutorDeps = {
function createConfig(overrides?: Partial<TmuxConfig>): TmuxConfig {
return {
enabled: true,
isolation: "inline",
layout: "main-horizontal",
main_pane_size: 55,
main_pane_min_width: 120,
@@ -50,6 +53,7 @@ function createWindowState(overrides?: Partial<WindowState>): WindowState {
function createContext(overrides?: Partial<ExecuteContext>): ExecuteContext {
return {
config: createConfig(),
directory: "/tmp/omo-project",
serverUrl: "http://localhost:4096",
windowState: createWindowState(),
...overrides,
@@ -90,7 +94,7 @@ describe("executeAction", () => {
test("does not apply layout when spawn fails", async () => {
// given
mockSpawnTmuxPane.mockImplementationOnce(async () => ({ success: false }))
mockSpawnTmuxPane.mockImplementation(async (): Promise<SpawnPaneResult> => ({ success: false }))
// when
const result = await executeActionWithDeps(
@@ -109,5 +113,6 @@ describe("executeAction", () => {
expect(result).toEqual({ success: false, paneId: undefined })
expect(mockApplyLayout).not.toHaveBeenCalled()
expect(mockEnforceMainPaneWidth).not.toHaveBeenCalled()
mockSpawnTmuxPane.mockImplementation(async (): Promise<SpawnPaneResult> => ({ success: true, paneId: "%7" }))
})
})

View File

@@ -10,10 +10,7 @@ import {
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { queryWindowState } from "./pane-state-querier"
import { log } from "../../shared"
import type {
ActionResult,
ActionExecutorDeps,
} from "./action-executor-core"
import type { ActionResult } from "./action-executor-core"
export type { ActionExecutorDeps, ActionResult } from "./action-executor-core"
@@ -25,6 +22,7 @@ export interface ExecuteActionsResult {
export interface ExecuteContext {
config: TmuxConfig
directory: string
serverUrl: string
windowState: WindowState
sourcePaneId?: string
@@ -79,10 +77,11 @@ export async function executeAction(
const result = await replaceTmuxPane(
action.paneId,
action.newSessionId,
action.description,
ctx.config,
ctx.serverUrl
)
action.description,
ctx.config,
ctx.serverUrl,
ctx.directory,
)
if (result.success) {
await enforceLayoutAndMainPane(ctx)
}
@@ -94,12 +93,13 @@ export async function executeAction(
const result = await spawnTmuxPane(
action.sessionId,
action.description,
ctx.config,
ctx.serverUrl,
action.targetPaneId,
action.splitDirection
)
action.description,
ctx.config,
ctx.serverUrl,
ctx.directory,
action.targetPaneId,
action.splitDirection
)
if (result.success) {
await enforceLayoutAndMainPane(ctx)

View File

@@ -30,6 +30,7 @@ export interface SessionPollingController {
export function createSessionPollingController(params: {
client: OpencodeClient
tmuxConfig: TmuxConfig
directory: string
serverUrl: string
sourcePaneId: string | undefined
sessions: Map<string, TrackedSession>
@@ -49,7 +50,12 @@ export function createSessionPollingController(params: {
if (state) {
await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId },
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
{
config: params.tmuxConfig,
directory: params.directory,
serverUrl: params.serverUrl,
windowState: state,
},
)
}

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import type { TmuxCommandResult } from "../runner"
const paneReplaceSpecifier = import.meta.resolve("./pane-replace")
@@ -29,6 +30,29 @@ const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
async function loadReplaceTmuxPane(): Promise<typeof import("./pane-replace").replaceTmuxPane> {
const module = await import(`${paneReplaceSpecifier}?test=${crypto.randomUUID()}`)
return module.replaceTmuxPane
@@ -49,10 +73,18 @@ describe("replaceTmuxPane runner integration", () => {
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
@@ -60,16 +92,24 @@ describe("replaceTmuxPane runner integration", () => {
it("#given existing pane #when replaceTmuxPane called #then delegates send-keys, respawn-pane, and select-pane to shared runner", async () => {
// given
const replaceTmuxPane = await loadReplaceTmuxPane()
const directory = "/tmp/omo-project/(replace)"
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
// when
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234")
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
// then
const sendKeysCall = getRunTmuxCommandCall(0)
const respawnCall = getRunTmuxCommandCall(1)
const selectPaneCall = getRunTmuxCommandCall(2)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(runTmuxCommandMock.mock.calls).toEqual([
[expect.any(String), ["send-keys", "-t", "%42", "C-c"]],
[expect.any(String), expect.arrayContaining(["respawn-pane", "-k", "-t", "%42"])],
[expect.any(String), ["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]],
])
expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"])
expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"])
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
const respawnCommand = respawnCall[1][4]
if (respawnCommand === undefined) {
throw new Error("Expected respawn-pane command")
}
expect(respawnCommand).toContain(` --dir ${escapedDirectory}`)
})
})

View File

@@ -10,6 +10,7 @@ export async function replaceTmuxPane(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
@@ -35,7 +36,8 @@ export async function replaceTmuxPane(
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId} --dir ${escapedDirectory}"`
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd])

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import type { TmuxCommandResult } from "../runner"
const paneSpawnSpecifier = import.meta.resolve("./pane-spawn")
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
async function loadSpawnTmuxPane(): Promise<typeof import("./pane-spawn").spawnTmuxPane> {
const module = await import(`${paneSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxPane
@@ -53,9 +77,17 @@ describe("spawnTmuxPane runner integration", () => {
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
@@ -64,19 +96,22 @@ describe("spawnTmuxPane runner integration", () => {
it("#given healthy tmux environment #when spawnTmuxPane called #then delegates split-window and select-pane to shared runner", async () => {
// given
const spawnTmuxPane = await loadSpawnTmuxPane()
const directory = "/tmp/omo-project/(pane)"
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
// when
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "%0")
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0")
// then
const firstCall = getRunTmuxCommandCall(0)
const secondCall = getRunTmuxCommandCall(1)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(runTmuxCommandMock.mock.calls[0]).toEqual([
expect.any(String),
expect.arrayContaining(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"]),
])
expect(runTmuxCommandMock.mock.calls[1]).toEqual([
expect.any(String),
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
])
expect(firstCall[1].slice(0, 8)).toEqual(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"])
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
const splitCommand = firstCall[1][8]
if (splitCommand === undefined) {
throw new Error("Expected split-window command")
}
expect(splitCommand).toContain(` --dir ${escapedDirectory}`)
})
})

View File

@@ -11,6 +11,7 @@ export async function spawnTmuxPane(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
targetPaneId?: string,
splitDirection: SplitDirection = "-h",
): Promise<SpawnPaneResult> {
@@ -53,7 +54,8 @@ export async function spawnTmuxPane(
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId} --dir ${escapedDirectory}"`
const args = [
"split-window",

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import type { TmuxCommandResult } from "../runner"
const sessionSpawnSpecifier = import.meta.resolve("./session-spawn")
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
async function loadSpawnTmuxSession(): Promise<typeof import("./session-spawn").spawnTmuxSession> {
const module = await import(`${sessionSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxSession
@@ -53,11 +77,19 @@ describe("spawnTmuxSession runner integration", () => {
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "120,40", stdout: "120,40", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "", exitCode: 1 })
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "120,40", stdout: "120,40", stderr: "", exitCode: 0 },
{ success: false, output: "", stdout: "", stderr: "", exitCode: 1 },
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
@@ -66,24 +98,29 @@ describe("spawnTmuxSession runner integration", () => {
it("#given source pane available #when spawnTmuxSession called #then delegates display, has-session, new-session, and select-pane to shared runner", async () => {
// given
const spawnTmuxSession = await loadSpawnTmuxSession()
const directory = "/tmp/omo-project/(session)"
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
// when
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "%0")
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0")
// then
const displayCall = getRunTmuxCommandCall(0)
const hasSessionCall = getRunTmuxCommandCall(1)
const newSessionCall = getRunTmuxCommandCall(2)
const selectPaneCall = getRunTmuxCommandCall(3)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(1,
expect.any(String),
["display", "-p", "-t", "%0", "#{window_width},#{window_height}"],
)
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, expect.any(String), ["has-session", "-t", expect.stringContaining("omo-agents-")])
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(3,
expect.any(String),
expect.arrayContaining(["new-session", "-d", "-s", expect.stringContaining("omo-agents-")]),
)
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(4,
expect.any(String),
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
)
expect(displayCall[1]).toEqual(["display", "-p", "-t", "%0", "#{window_width},#{window_height}"])
expect(hasSessionCall[1][0]).toBe("has-session")
expect(hasSessionCall[1][1]).toBe("-t")
expect(hasSessionCall[1][2]?.startsWith("omo-agents-")).toBe(true)
expect(newSessionCall[1].slice(0, 4)).toEqual(["new-session", "-d", "-s", newSessionCall[1][3]])
expect(String(newSessionCall[1][3]).startsWith("omo-agents-")).toBe(true)
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
const newSessionCommand = newSessionCall[1][newSessionCall[1].length - 1]
if (newSessionCommand === undefined) {
throw new Error("Expected new-session command")
}
expect(newSessionCommand).toContain(` --dir ${escapedDirectory}`)
})
})

View File

@@ -37,6 +37,7 @@ export async function spawnTmuxSession(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
sourcePaneId?: string,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
@@ -77,7 +78,8 @@ export async function spawnTmuxSession(
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId}"`
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"`
const sizeArgs: string[] = []
if (sourcePaneId) {

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import type { TmuxCommandResult } from "../runner"
const windowSpawnSpecifier = import.meta.resolve("./window-spawn")
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
async function loadSpawnTmuxWindow(): Promise<typeof import("./window-spawn").spawnTmuxWindow> {
const module = await import(`${windowSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxWindow
@@ -53,9 +77,17 @@ describe("spawnTmuxWindow runner integration", () => {
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
@@ -64,19 +96,22 @@ describe("spawnTmuxWindow runner integration", () => {
it("#given healthy tmux environment #when spawnTmuxWindow called #then delegates new-window and select-pane to shared runner", async () => {
// given
const spawnTmuxWindow = await loadSpawnTmuxWindow()
const directory = "/tmp/omo-project/(window)"
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
// when
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234")
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
// then
const firstCall = getRunTmuxCommandCall(0)
const secondCall = getRunTmuxCommandCall(1)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(runTmuxCommandMock.mock.calls[0]).toEqual([
expect.any(String),
expect.arrayContaining(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"]),
])
expect(runTmuxCommandMock.mock.calls[1]).toEqual([
expect.any(String),
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
])
expect(firstCall[1].slice(0, 7)).toEqual(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"])
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
const newWindowCommand = firstCall[1][7]
if (newWindowCommand === undefined) {
throw new Error("Expected new-window command")
}
expect(newWindowCommand).toContain(` --dir ${escapedDirectory}`)
})
})

View File

@@ -12,6 +12,7 @@ export async function spawnTmuxWindow(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
@@ -51,7 +52,8 @@ export async function spawnTmuxWindow(
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId}"`
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"`
const args = [
"new-window",