Merge pull request #3827 from ShishaBoyTJ/fix/tmux-subagent-single-dispatch

fix: support cmux tmux compatibility
This commit is contained in:
acamq
2026-05-10 19:41:10 -06:00
committed by GitHub
9 changed files with 212 additions and 19 deletions
@@ -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 { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect"
let tmuxPath: string | 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 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<string | 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"], {
env: process.env,
stdout: "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> {
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()
+12 -1
View File
@@ -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",
})