fix: follow up cmux probing and tmux session resolution

This commit is contained in:
Kenny
2026-03-29 16:07:41 +08:00
parent 73f5ae968f
commit f7ac464194
7 changed files with 201 additions and 11 deletions

View File

@@ -578,7 +578,7 @@ The runtime evaluates tmux and cmux availability to determine operating mode:
| ------------------ | ---------------------------------------------- | ------------ | ------------- |
| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux (if capable), else desktop |
| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop |
| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux (if capable), else desktop |
| `cmux-notify-only` | Live cmux, tmux pane control unavailable | none | cmux (if capable), else desktop |
| `none` | Neither tmux nor cmux available | none | desktop |
#### Backend Precedence Semantics

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { OpenClawConfig } from "../types"
const wakeGatewayMock = mock(async () => ({
gateway: "http-gateway",
success: true,
statusCode: 200,
}))
const wakeCommandGatewayMock = mock(async () => ({
gateway: "command-gateway",
success: true,
}))
const interpolateInstructionMock = mock((template: string) => template)
const getCurrentTmuxSessionMock = mock(async () => "workspace-main")
const captureTmuxPaneMock = mock(async () => null)
const startReplyListenerMock = mock(async () => {})
const stopReplyListenerMock = mock(() => {})
mock.module("../dispatcher", () => ({
wakeGateway: wakeGatewayMock,
wakeCommandGateway: wakeCommandGatewayMock,
interpolateInstruction: interpolateInstructionMock,
}))
mock.module("../tmux", () => ({
getCurrentTmuxSession: getCurrentTmuxSessionMock,
captureTmuxPane: captureTmuxPaneMock,
}))
mock.module("../reply-listener", () => ({
startReplyListener: startReplyListenerMock,
stopReplyListener: stopReplyListenerMock,
}))
const { wakeOpenClaw } = await import("../index")
describe("wakeOpenClaw tmux session resolution", () => {
beforeEach(() => {
wakeGatewayMock.mockClear()
wakeCommandGatewayMock.mockClear()
interpolateInstructionMock.mockClear()
getCurrentTmuxSessionMock.mockClear()
captureTmuxPaneMock.mockClear()
startReplyListenerMock.mockClear()
stopReplyListenerMock.mockClear()
})
test("awaits asynchronous tmux session lookup before dispatch", async () => {
const config: OpenClawConfig = {
enabled: true,
gateways: {
commandGateway: {
type: "command",
method: "POST",
command: "echo {{tmuxSession}}",
},
},
hooks: {
"session-start": {
enabled: true,
gateway: "commandGateway",
instruction: "tmux session: {{tmuxSession}}",
},
},
}
await wakeOpenClaw(config, "session-start", {})
expect(getCurrentTmuxSessionMock).toHaveBeenCalledTimes(1)
expect(interpolateInstructionMock).toHaveBeenCalledTimes(1)
expect(interpolateInstructionMock.mock.calls[0]?.[1]?.tmuxSession).toBe("workspace-main")
expect(wakeCommandGatewayMock).toHaveBeenCalledTimes(1)
expect(wakeCommandGatewayMock.mock.calls[0]?.[2]?.tmuxSession).toBe("workspace-main")
})
})

View File

@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test"
import { analyzePaneContent } from "../tmux"
import { beforeEach, describe, expect, test } from "bun:test"
import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver"
import { resetResolvedMultiplexerRuntimeForTesting } from "../../shared/tmux"
import { analyzePaneContent, getCurrentTmuxSession } from "../tmux"
describe("openclaw tmux helpers", () => {
beforeEach(() => {
resetMultiplexerPathCacheForTesting()
resetResolvedMultiplexerRuntimeForTesting()
})
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
const content = "opencode\nAsk anything...\nRun /help"
expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1)
@@ -10,4 +17,38 @@ describe("openclaw tmux helpers", () => {
test("analyzePaneContent returns zero confidence for empty content", () => {
expect(analyzePaneContent(null).confidence).toBe(0)
})
test("getCurrentTmuxSession does not synthesize a session from TMUX_PANE", async () => {
const originalTmux = process.env.TMUX
const originalTmuxPane = process.env.TMUX_PANE
const originalDisableTmuxFlag = process.env.OH_MY_OPENCODE_DISABLE_TMUX
try {
process.env.TMUX = "/tmp/tmux-501/default,1,0"
process.env.TMUX_PANE = "%42"
process.env.OH_MY_OPENCODE_DISABLE_TMUX = "1"
const sessionName = await getCurrentTmuxSession()
expect(sessionName).toBeNull()
} finally {
if (originalTmux === undefined) {
delete process.env.TMUX
} else {
process.env.TMUX = originalTmux
}
if (originalTmuxPane === undefined) {
delete process.env.TMUX_PANE
} else {
process.env.TMUX_PANE = originalTmuxPane
}
if (originalDisableTmuxFlag === undefined) {
delete process.env.OH_MY_OPENCODE_DISABLE_TMUX
} else {
process.env.OH_MY_OPENCODE_DISABLE_TMUX = originalDisableTmuxFlag
}
}
})
})

View File

@@ -55,7 +55,10 @@ export async function wakeOpenClaw(
...(replyThread !== undefined && { replyThread }),
}
const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined
const tmuxSession =
enrichedContext.tmuxSession
?? (await getCurrentTmuxSession())
?? undefined
let tmuxTail = enrichedContext.tmuxTail
if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) {

View File

@@ -6,7 +6,7 @@ import {
isInsideTmux,
} from "../shared/tmux"
export function getCurrentTmuxSession(): string | null {
export async function getCurrentTmuxSession(): Promise<string | null> {
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
return null
@@ -19,9 +19,7 @@ export function getCurrentTmuxSession(): string | null {
const paneId = getCurrentPaneId(resolvedMultiplexer ?? undefined)
if (!paneId) return null
const match = paneId.match(/(\d+)$/)
return match ? `session-${match[1]}` : null
// Reference tmux.js gets session name via `tmux display-message -p '#S'`
return getTmuxSessionName()
}
export async function getTmuxSessionName(): Promise<string | null> {

View File

@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
classifyCmuxEndpoint,
isConnectionRefusedText,
probeCmuxNotificationCapability,
probeCmuxReachability,
probeTmuxRuntime,
resetMultiplexerPathCacheForTesting,
@@ -55,6 +59,62 @@ describe("tmux-path-resolver probe environment", () => {
whichSpy.mockRestore()
}
})
test("probeTmuxRuntime honors explicit PATH removal in custom environment", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeTmuxRuntime({
environment: {
PATH: undefined,
TMUX: "/tmp/tmux-501/default,1,0",
TMUX_PANE: "%1",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"tmux",
{ PATH: "" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => {
const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-"))
const fakeCmuxPath = join(tempDirectory, "cmux")
const slowCmuxScript = `#!/bin/sh
if [ "$1" = "notify" ]; then
trap '' TERM
/bin/sleep 1
exit 0
fi
exit 1
`
writeFileSync(fakeCmuxPath, slowCmuxScript)
chmodSync(fakeCmuxPath, 0o755)
try {
const startedAt = Date.now()
const probe = await probeCmuxNotificationCapability({
cmuxPath: fakeCmuxPath,
environment: {
PATH: tempDirectory,
},
timeoutMs: 40,
})
const elapsedMs = Date.now() - startedAt
expect(probe.failureKind).toBe("timeout")
expect(elapsedMs).toBeLessThan(500)
} finally {
rmSync(tempDirectory, { recursive: true, force: true })
}
})
})
describe("tmux-path-resolver cmux endpoint helpers", () => {

View File

@@ -240,7 +240,16 @@ async function runProbeCommand(
clearTimeout(timeoutHandle)
}
const exitCode = timedOut ? null : await proc.exited.catch(() => null)
if (timedOut) {
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: true,
}
}
const exitCode = await proc.exited.catch(() => null)
const stdout = await new Response(proc.stdout).text().catch(() => "")
const stderr = await new Response(proc.stderr).text().catch(() => "")
@@ -266,8 +275,12 @@ function findCommandPath(
): string | null {
try {
const probeEnvironment = toProbeEnvironment(environment)
const whichOptions =
probeEnvironment.PATH !== undefined
const hasExplicitPathOverride =
environment !== undefined
&& Object.prototype.hasOwnProperty.call(environment, "PATH")
const whichOptions = hasExplicitPathOverride
? { PATH: probeEnvironment.PATH ?? "" }
: probeEnvironment.PATH !== undefined
? { PATH: probeEnvironment.PATH }
: undefined