From 8236d7d6b864654c1500ee5adb349efec8838ecb Mon Sep 17 00:00:00 2001 From: ShishaBoyTJ Date: Thu, 7 May 2026 13:01:33 +0900 Subject: [PATCH] fix: support cmux tmux compatibility --- docs/reference/features.md | 2 + script/run-ci-tests.ts | 1 + src/shared/tmux/runner.test.ts | 59 ++++++++++++++- src/shared/tmux/runner.ts | 16 +++-- .../tmux-path-resolver.test.ts | 72 +++++++++++++++++++ .../interactive-bash/tmux-path-resolver.ts | 57 +++++++++++++-- 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 src/tools/interactive-bash/tmux-path-resolver.test.ts diff --git a/docs/reference/features.md b/docs/reference/features.md index a1b8ba6e2..eaafd7e22 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -92,6 +92,8 @@ When running inside tmux: - Auto-cleanup when agents complete - **Stable agent ordering**: core-agent tab cycling is deterministic via injected runtime order field (Sisyphus: 1, Hephaestus: 2, Prometheus: 3, Atlas: 4) +When running inside cmux (`cmux omo`), the same pane integration is routed through cmux's tmux compatibility command. OMO detects the cmux environment from `CMUX_SOCKET_PATH` or a cmux-provided `TMUX` value, so `tmux.enabled` can create cmux panes even when a real `tmux` binary is not installed. + Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`. ### Team Mode (experimental, OFF by default) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index e23cb41ac..cae400858 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -23,6 +23,7 @@ const ALWAYS_ISOLATED_TEST_FILES = [ "src/openclaw/__tests__/reply-listener-discord.test.ts", "src/tools/background-task/create-background-output.blocking.test.ts", "src/tools/background-task/tools.test.ts", + "src/tools/interactive-bash/tmux-path-resolver.test.ts", "src/tools/task/task-list.test.ts", ] as const diff --git a/src/shared/tmux/runner.test.ts b/src/shared/tmux/runner.test.ts index 9832c99b2..d0edeac5e 100644 --- a/src/shared/tmux/runner.test.ts +++ b/src/shared/tmux/runner.test.ts @@ -1,6 +1,6 @@ /// -import { afterAll, describe, expect, test } from "bun:test" +import { afterAll, beforeEach, describe, expect, test } from "bun:test" import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import os from "node:os" @@ -9,6 +9,9 @@ import path from "node:path" import { runTmuxCommand } from "./runner" const temporaryDirectories: string[] = [] +const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH +const originalTmux = process.env.TMUX +const originalPath = process.env.PATH async function createTemporaryDirectory(): Promise { const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-")) @@ -21,7 +24,39 @@ async function readInvocationCount(counterFilePath: string): Promise { return Number.parseInt(count, 10) } +async function createFakeCmux(directoryPath: string, argsFilePath: string): Promise { + const cmuxPath = path.join(directoryPath, "cmux") + const script = [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$1.args\"", + "printf '%s\\n' '%42'", + ].join("\n") + await fs.writeFile(cmuxPath, script.replace("$1.args", argsFilePath), "utf8") + await fs.chmod(cmuxPath, 0o755) + return cmuxPath +} + +beforeEach(() => { + delete process.env.CMUX_SOCKET_PATH + delete process.env.TMUX + process.env.PATH = originalPath +}) + afterAll(async () => { + if (originalCmuxSocketPath === undefined) { + delete process.env.CMUX_SOCKET_PATH + } else { + process.env.CMUX_SOCKET_PATH = originalCmuxSocketPath + } + + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + + process.env.PATH = originalPath + for (const directoryPath of temporaryDirectories) { await fs.rm(directoryPath, { recursive: true, force: true }) } @@ -124,4 +159,26 @@ describe("runTmuxCommand", () => { expect(success).toBe(true) expect(output).toBe("%9") }) + + test("#given cmux environment #when run #then delegates through cmux tmux compatibility command", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const argsFilePath = path.join(temporaryDirectory, "cmux.args") + const cmuxPath = await createFakeCmux(temporaryDirectory, argsFilePath) + process.env.CMUX_SOCKET_PATH = path.join(temporaryDirectory, "cmux.sock") + process.env.PATH = `${temporaryDirectory}${path.delimiter}${originalPath ?? ""}` + + // when + const result = await runTmuxCommand(cmuxPath, ["display-message", "-p", "#{pane_id}"]) + + // then + expect(result).toEqual({ + success: true, + output: "%42", + stdout: "%42", + stderr: "", + exitCode: 0, + }) + await expect(fs.readFile(argsFilePath, "utf8")).resolves.toBe("__tmux-compat\ndisplay-message\n-p\n#{pane_id}\n") + }) }) diff --git a/src/shared/tmux/runner.ts b/src/shared/tmux/runner.ts index 5ad86395c..b61995fbc 100644 --- a/src/shared/tmux/runner.ts +++ b/src/shared/tmux/runner.ts @@ -36,13 +36,19 @@ function isTerminalTmuxError(stderr: string): boolean { * `cmux __tmux-compat` so they become native cmux splits instead of * failing because there is no real tmux server running. */ -function resolveTmuxExecutable(tmuxPath: string): string[] { - const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) || +function isCmuxCompatEnvironment(): boolean { + return Boolean(process.env.CMUX_SOCKET_PATH) || process.env.TMUX?.includes("cmuxterm") === true - if (inCmux) { - return ["cmux", "__tmux-compat"] +} + +function resolveTmuxExecutable(tmuxPath: string): string[] { + if (!isCmuxCompatEnvironment()) { + return [tmuxPath] } - return [tmuxPath] + + const executableName = tmuxPath.split(/[\\/]/).pop() + const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux" + return [cmuxExecutable, "__tmux-compat"] } async function runTmuxCommandOnce(tmuxPath: string, args: Array, timeoutMs?: number): Promise { diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts new file mode 100644 index 000000000..be0247095 --- /dev/null +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -0,0 +1,72 @@ +/// + +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { getTmuxPath, resetTmuxPathCacheForTesting } from "./tmux-path-resolver" + +const temporaryDirectories: string[] = [] +const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH +const originalTmux = process.env.TMUX +const originalPath = process.env.PATH + +async function createTemporaryDirectory(): Promise { + const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-path-resolver-")) + temporaryDirectories.push(directoryPath) + return directoryPath +} + +async function createExecutable(directoryPath: string, name: string, script: string): Promise { + const executablePath = path.join(directoryPath, name) + await fs.writeFile(executablePath, script, "utf8") + await fs.chmod(executablePath, 0o755) + return executablePath +} + +beforeEach(() => { + resetTmuxPathCacheForTesting() + delete process.env.CMUX_SOCKET_PATH + delete process.env.TMUX + process.env.PATH = originalPath +}) + +afterAll(async () => { + resetTmuxPathCacheForTesting() + + if (originalCmuxSocketPath === undefined) { + delete process.env.CMUX_SOCKET_PATH + } else { + process.env.CMUX_SOCKET_PATH = originalCmuxSocketPath + } + + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + + process.env.PATH = originalPath + + for (const directoryPath of temporaryDirectories) { + await fs.rm(directoryPath, { recursive: true, force: true }) + } +}) + +describe("getTmuxPath", () => { + test("#given cmux environment #when cmux is available #then returns cmux without requiring a real tmux binary", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const cmuxPath = await createExecutable(temporaryDirectory, "cmux", "#!/bin/sh\nexit 0\n") + await createExecutable(temporaryDirectory, "tmux", "#!/bin/sh\nexit 1\n") + process.env.CMUX_SOCKET_PATH = path.join(temporaryDirectory, "cmux.sock") + process.env.PATH = `${temporaryDirectory}${path.delimiter}${originalPath ?? ""}` + + // when + const resolvedPath = await getTmuxPath() + + // then + expect(path.basename(resolvedPath ?? "")).toBe(path.basename(cmuxPath)) + }) +}) diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 1187fdef0..0562caa0d 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -2,13 +2,24 @@ import { spawn } from "../../shared/bun-spawn-shim" let tmuxPath: string | null = null let initPromise: Promise | null = null +let tmuxPathEnvironmentKey: "cmux" | "tmux" | null = null -async function findTmuxPath(): Promise { +function isCmuxCompatEnvironment(): boolean { + return Boolean(process.env.CMUX_SOCKET_PATH) || + process.env.TMUX?.includes("cmuxterm") === true +} + +function getEnvironmentKey(): "cmux" | "tmux" { + return isCmuxCompatEnvironment() ? "cmux" : "tmux" +} + +async function findCommandPath(command: string): Promise { const isWindows = process.platform === "win32" const cmd = isWindows ? "where" : "which" try { - const proc = spawn([cmd, "tmux"], { + const proc = spawn([cmd, command], { + env: process.env, stdout: "pipe", stderr: "pipe", }) @@ -25,7 +36,21 @@ async function findTmuxPath(): Promise { return null } + return path + } catch { + return null + } +} + +async function findVerifiedTmuxPath(): Promise { + const path = await findCommandPath("tmux") + if (!path) { + return null + } + + try { const verifyProc = spawn([path, "-V"], { + env: process.env, stdout: "pipe", stderr: "pipe", }) @@ -41,18 +66,34 @@ async function findTmuxPath(): Promise { } } +async function findTmuxPath(): Promise { + if (isCmuxCompatEnvironment()) { + const cmuxPath = await findCommandPath("cmux") + if (cmuxPath) { + return cmuxPath + } + } + + return findVerifiedTmuxPath() +} + export async function getTmuxPath(): Promise { - if (tmuxPath !== null) { + const environmentKey = getEnvironmentKey() + if (tmuxPath !== null && tmuxPathEnvironmentKey === environmentKey) { return tmuxPath } - if (initPromise) { + if (initPromise && tmuxPathEnvironmentKey === environmentKey) { return initPromise } + tmuxPathEnvironmentKey = environmentKey + const promiseEnvironmentKey = environmentKey initPromise = (async () => { const path = await findTmuxPath() - tmuxPath = path + if (tmuxPathEnvironmentKey === promiseEnvironmentKey) { + tmuxPath = path + } return path })() @@ -63,6 +104,12 @@ export function getCachedTmuxPath(): string | null { return tmuxPath } +export function resetTmuxPathCacheForTesting(): void { + tmuxPath = null + initPromise = null + tmuxPathEnvironmentKey = null +} + export function startBackgroundCheck(): void { if (!initPromise) { initPromise = getTmuxPath()