diff --git a/docs/reference/features.md b/docs/reference/features.md
index 965a1457b..0cbd05945 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 defaults to Sisyphus, Hephaestus, Prometheus, Atlas, and can be customized with `agent_order`
+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/cmux-detect.ts b/src/shared/tmux/cmux-detect.ts
new file mode 100644
index 000000000..202733c77
--- /dev/null
+++ b/src/shared/tmux/cmux-detect.ts
@@ -0,0 +1,11 @@
+/**
+ * Detect whether we are running inside cmux (cmux omo).
+ * When cmux-omo sets up the environment it injects a tmux shim and sets
+ * CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to
+ * `cmux __tmux-compat` so they become native cmux splits instead of
+ * failing because there is no real tmux server running.
+ */
+export function isCmuxCompatEnvironment(): boolean {
+ return Boolean(process.env.CMUX_SOCKET_PATH) ||
+ process.env.TMUX?.includes("cmuxterm") === true
+}
diff --git a/src/shared/tmux/index.ts b/src/shared/tmux/index.ts
index b523bf642..b8ef46b75 100644
--- a/src/shared/tmux/index.ts
+++ b/src/shared/tmux/index.ts
@@ -1,4 +1,5 @@
export * from "./types"
export * from "./constants"
+export * from "./cmux-detect"
export * from "./runner"
export * from "./tmux-utils"
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..6bbd2cf4b 100644
--- a/src/shared/tmux/runner.ts
+++ b/src/shared/tmux/runner.ts
@@ -1,4 +1,5 @@
import { spawn } from "../bun-spawn-shim"
+import { isCmuxCompatEnvironment } from "./cmux-detect"
type RunTmuxOptions = {
retry?: number
@@ -29,20 +30,14 @@ function isTerminalTmuxError(stderr: string): boolean {
return TERMINAL_TMUX_ERROR_PATTERN.test(stderr)
}
-/**
- * Detect whether we are running inside cmux (cmux omo).
- * When cmux-omo sets up the environment it injects a tmux shim and sets
- * CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to
- * `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) ||
- process.env.TMUX?.includes("cmuxterm") === true
- if (inCmux) {
- return ["cmux", "__tmux-compat"]
+ 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..2ef2324eb 100644
--- a/src/tools/interactive-bash/tmux-path-resolver.ts
+++ b/src/tools/interactive-bash/tmux-path-resolver.ts
@@ -1,14 +1,21 @@
import { spawn } from "../../shared/bun-spawn-shim"
+import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect"
let tmuxPath: string | null = null
let initPromise: Promise | null = null
+let tmuxPathEnvironmentKey: "cmux" | "tmux" | null = null
-async function findTmuxPath(): Promise {
+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 +32,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 +62,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 +100,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()
diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts
index a0795ee36..21a03cf7f 100644
--- a/src/tools/interactive-bash/tools.ts
+++ b/src/tools/interactive-bash/tools.ts
@@ -1,8 +1,19 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
+import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver"
+function resolveTmuxExecutable(tmuxPath: string): string[] {
+ if (!isCmuxCompatEnvironment()) {
+ return [tmuxPath]
+ }
+
+ const executableName = tmuxPath.split(/[\\/]/).pop()
+ const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux"
+ return [cmuxExecutable, "__tmux-compat"]
+}
+
/**
* Quote-aware command tokenizer with escape handling
* Handles single/double quotes and backslash escapes without external dependencies
@@ -90,7 +101,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
}
- const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
+ const proc = spawnWithWindowsHide([...resolveTmuxExecutable(tmuxPath), ...parts], {
stdout: "pipe",
stderr: "pipe",
})