Merge pull request #3827 from ShishaBoyTJ/fix/tmux-subagent-single-dispatch
fix: support cmux tmux compatibility
This commit is contained in:
@@ -92,6 +92,8 @@ When running inside tmux:
|
|||||||
- Auto-cleanup when agents complete
|
- 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`
|
- **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`.
|
Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`.
|
||||||
|
|
||||||
### Team Mode (experimental, OFF by default)
|
### Team Mode (experimental, OFF by default)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ALWAYS_ISOLATED_TEST_FILES = [
|
|||||||
"src/openclaw/__tests__/reply-listener-discord.test.ts",
|
"src/openclaw/__tests__/reply-listener-discord.test.ts",
|
||||||
"src/tools/background-task/create-background-output.blocking.test.ts",
|
"src/tools/background-task/create-background-output.blocking.test.ts",
|
||||||
"src/tools/background-task/tools.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",
|
"src/tools/task/task-list.test.ts",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./types"
|
export * from "./types"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
|
export * from "./cmux-detect"
|
||||||
export * from "./runner"
|
export * from "./runner"
|
||||||
export * from "./tmux-utils"
|
export * from "./tmux-utils"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { afterAll, describe, expect, test } from "bun:test"
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
@@ -9,6 +9,9 @@ import path from "node:path"
|
|||||||
import { runTmuxCommand } from "./runner"
|
import { runTmuxCommand } from "./runner"
|
||||||
|
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH
|
||||||
|
const originalTmux = process.env.TMUX
|
||||||
|
const originalPath = process.env.PATH
|
||||||
|
|
||||||
async function createTemporaryDirectory(): Promise<string> {
|
async function createTemporaryDirectory(): Promise<string> {
|
||||||
const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-"))
|
const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-"))
|
||||||
@@ -21,7 +24,39 @@ async function readInvocationCount(counterFilePath: string): Promise<number> {
|
|||||||
return Number.parseInt(count, 10)
|
return Number.parseInt(count, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createFakeCmux(directoryPath: string, argsFilePath: string): Promise<string> {
|
||||||
|
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 () => {
|
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) {
|
for (const directoryPath of temporaryDirectories) {
|
||||||
await fs.rm(directoryPath, { recursive: true, force: true })
|
await fs.rm(directoryPath, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -124,4 +159,26 @@ describe("runTmuxCommand", () => {
|
|||||||
expect(success).toBe(true)
|
expect(success).toBe(true)
|
||||||
expect(output).toBe("%9")
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from "../bun-spawn-shim"
|
import { spawn } from "../bun-spawn-shim"
|
||||||
|
import { isCmuxCompatEnvironment } from "./cmux-detect"
|
||||||
|
|
||||||
type RunTmuxOptions = {
|
type RunTmuxOptions = {
|
||||||
retry?: number
|
retry?: number
|
||||||
@@ -29,20 +30,14 @@ function isTerminalTmuxError(stderr: string): boolean {
|
|||||||
return TERMINAL_TMUX_ERROR_PATTERN.test(stderr)
|
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[] {
|
function resolveTmuxExecutable(tmuxPath: string): string[] {
|
||||||
const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) ||
|
if (!isCmuxCompatEnvironment()) {
|
||||||
process.env.TMUX?.includes("cmuxterm") === true
|
return [tmuxPath]
|
||||||
if (inCmux) {
|
|
||||||
return ["cmux", "__tmux-compat"]
|
|
||||||
}
|
}
|
||||||
return [tmuxPath]
|
|
||||||
|
const executableName = tmuxPath.split(/[\\/]/).pop()
|
||||||
|
const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux"
|
||||||
|
return [cmuxExecutable, "__tmux-compat"]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTmuxCommandOnce(tmuxPath: string, args: Array<string>, timeoutMs?: number): Promise<TmuxCommandResult> {
|
async function runTmuxCommandOnce(tmuxPath: string, args: Array<string>, timeoutMs?: number): Promise<TmuxCommandResult> {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { spawn } from "../../shared/bun-spawn-shim"
|
import { spawn } from "../../shared/bun-spawn-shim"
|
||||||
|
import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect"
|
||||||
|
|
||||||
let tmuxPath: string | null = null
|
let tmuxPath: string | null = null
|
||||||
let initPromise: Promise<string | null> | null = null
|
let initPromise: Promise<string | null> | null = null
|
||||||
|
let tmuxPathEnvironmentKey: "cmux" | "tmux" | null = null
|
||||||
|
|
||||||
async function findTmuxPath(): Promise<string | null> {
|
function getEnvironmentKey(): "cmux" | "tmux" {
|
||||||
|
return isCmuxCompatEnvironment() ? "cmux" : "tmux"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCommandPath(command: string): Promise<string | null> {
|
||||||
const isWindows = process.platform === "win32"
|
const isWindows = process.platform === "win32"
|
||||||
const cmd = isWindows ? "where" : "which"
|
const cmd = isWindows ? "where" : "which"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = spawn([cmd, "tmux"], {
|
const proc = spawn([cmd, command], {
|
||||||
|
env: process.env,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
@@ -25,7 +32,21 @@ async function findTmuxPath(): Promise<string | null> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findVerifiedTmuxPath(): Promise<string | null> {
|
||||||
|
const path = await findCommandPath("tmux")
|
||||||
|
if (!path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const verifyProc = spawn([path, "-V"], {
|
const verifyProc = spawn([path, "-V"], {
|
||||||
|
env: process.env,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
@@ -41,18 +62,34 @@ async function findTmuxPath(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findTmuxPath(): Promise<string | null> {
|
||||||
|
if (isCmuxCompatEnvironment()) {
|
||||||
|
const cmuxPath = await findCommandPath("cmux")
|
||||||
|
if (cmuxPath) {
|
||||||
|
return cmuxPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findVerifiedTmuxPath()
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTmuxPath(): Promise<string | null> {
|
export async function getTmuxPath(): Promise<string | null> {
|
||||||
if (tmuxPath !== null) {
|
const environmentKey = getEnvironmentKey()
|
||||||
|
if (tmuxPath !== null && tmuxPathEnvironmentKey === environmentKey) {
|
||||||
return tmuxPath
|
return tmuxPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initPromise) {
|
if (initPromise && tmuxPathEnvironmentKey === environmentKey) {
|
||||||
return initPromise
|
return initPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tmuxPathEnvironmentKey = environmentKey
|
||||||
|
const promiseEnvironmentKey = environmentKey
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
const path = await findTmuxPath()
|
const path = await findTmuxPath()
|
||||||
tmuxPath = path
|
if (tmuxPathEnvironmentKey === promiseEnvironmentKey) {
|
||||||
|
tmuxPath = path
|
||||||
|
}
|
||||||
return path
|
return path
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -63,6 +100,12 @@ export function getCachedTmuxPath(): string | null {
|
|||||||
return tmuxPath
|
return tmuxPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetTmuxPathCacheForTesting(): void {
|
||||||
|
tmuxPath = null
|
||||||
|
initPromise = null
|
||||||
|
tmuxPathEnvironmentKey = null
|
||||||
|
}
|
||||||
|
|
||||||
export function startBackgroundCheck(): void {
|
export function startBackgroundCheck(): void {
|
||||||
if (!initPromise) {
|
if (!initPromise) {
|
||||||
initPromise = getTmuxPath()
|
initPromise = getTmuxPath()
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
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 { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||||
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
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
|
* Quote-aware command tokenizer with escape handling
|
||||||
* Handles single/double quotes and backslash escapes without external dependencies
|
* 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.`
|
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",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user