From 74c1942af54b1ff02da03757658955faa4e17e18 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 20 Apr 2026 18:05:56 +0900 Subject: [PATCH] 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 --session ` 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 --- .../tmux-subagent/action-executor-core.ts | 3 + .../tmux-subagent/action-executor.test.ts | 9 ++- src/features/tmux-subagent/action-executor.ts | 28 +++---- src/features/tmux-subagent/polling.ts | 8 +- .../tmux/tmux-utils/pane-replace.test.ts | 60 ++++++++++++--- src/shared/tmux/tmux-utils/pane-replace.ts | 4 +- .../tmux/tmux-utils/pane-spawn-runner.test.ts | 59 ++++++++++++--- src/shared/tmux/tmux-utils/pane-spawn.ts | 4 +- .../tmux/tmux-utils/session-spawn.test.ts | 75 ++++++++++++++----- src/shared/tmux/tmux-utils/session-spawn.ts | 4 +- .../tmux/tmux-utils/window-spawn.test.ts | 59 ++++++++++++--- src/shared/tmux/tmux-utils/window-spawn.ts | 4 +- 12 files changed, 243 insertions(+), 74 deletions(-) diff --git a/src/features/tmux-subagent/action-executor-core.ts b/src/features/tmux-subagent/action-executor-core.ts index 70a8f463e..75cc345c6 100644 --- a/src/features/tmux-subagent/action-executor-core.ts +++ b/src/features/tmux-subagent/action-executor-core.ts @@ -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, ) diff --git a/src/features/tmux-subagent/action-executor.test.ts b/src/features/tmux-subagent/action-executor.test.ts index 18e24b44b..fa695b5da 100644 --- a/src/features/tmux-subagent/action-executor.test.ts +++ b/src/features/tmux-subagent/action-executor.test.ts @@ -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> + +const mockSpawnTmuxPane = mock(async (): Promise => ({ 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 { 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 { function createContext(overrides?: Partial): 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 => ({ 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 => ({ success: true, paneId: "%7" })) }) }) diff --git a/src/features/tmux-subagent/action-executor.ts b/src/features/tmux-subagent/action-executor.ts index 9635ff7cb..0ebbd4378 100644 --- a/src/features/tmux-subagent/action-executor.ts +++ b/src/features/tmux-subagent/action-executor.ts @@ -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) diff --git a/src/features/tmux-subagent/polling.ts b/src/features/tmux-subagent/polling.ts index a438be488..a8b3dd925 100644 --- a/src/features/tmux-subagent/polling.ts +++ b/src/features/tmux-subagent/polling.ts @@ -30,6 +30,7 @@ export interface SessionPollingController { export function createSessionPollingController(params: { client: OpencodeClient tmuxConfig: TmuxConfig + directory: string serverUrl: string sourcePaneId: string | undefined sessions: Map @@ -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, + }, ) } diff --git a/src/shared/tmux/tmux-utils/pane-replace.test.ts b/src/shared/tmux/tmux-utils/pane-replace.test.ts index ec95f944f..29d65ebec 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.test.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.test.ts @@ -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 => "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 { 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 => { + 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}`) }) }) diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts index 3ebf26223..63bc68409 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -10,6 +10,7 @@ export async function replaceTmuxPane( description: string, config: TmuxConfig, serverUrl: string, + directory: string, ): Promise { 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]) diff --git a/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts index f34bc28e9..885880502 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts @@ -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 => true) const getTmuxPathMock = mock(async (): Promise => "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 { 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 => { + 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}`) }) }) diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts index 0081eb19f..cbccc7967 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -11,6 +11,7 @@ export async function spawnTmuxPane( description: string, config: TmuxConfig, serverUrl: string, + directory: string, targetPaneId?: string, splitDirection: SplitDirection = "-h", ): Promise { @@ -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", diff --git a/src/shared/tmux/tmux-utils/session-spawn.test.ts b/src/shared/tmux/tmux-utils/session-spawn.test.ts index 0b9dc179c..5e3c0b571 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.test.ts @@ -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 => true) const getTmuxPathMock = mock(async (): Promise => "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 { 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 => { + 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}`) }) }) diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index 458903f16..5a15c3225 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -37,6 +37,7 @@ export async function spawnTmuxSession( description: string, config: TmuxConfig, serverUrl: string, + directory: string, sourcePaneId?: string, ): Promise { 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) { diff --git a/src/shared/tmux/tmux-utils/window-spawn.test.ts b/src/shared/tmux/tmux-utils/window-spawn.test.ts index 7f7ace341..b14393d44 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.test.ts @@ -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 => true) const getTmuxPathMock = mock(async (): Promise => "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 { 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 => { + 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}`) }) }) diff --git a/src/shared/tmux/tmux-utils/window-spawn.ts b/src/shared/tmux/tmux-utils/window-spawn.ts index 1aad8e62b..eba853616 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.ts @@ -12,6 +12,7 @@ export async function spawnTmuxWindow( description: string, config: TmuxConfig, serverUrl: string, + directory: string, ): Promise { 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",