feat: integrate cmux-aware runtime with resilient notifications

Resolve tmux/cmux capability at startup so pane control remains tmux-driven while notifications prefer cmux and gracefully fall back to desktop notifications.
This commit is contained in:
Kenny
2026-03-29 13:56:31 +08:00
parent 448a8dc93d
commit 64a87a78d6
27 changed files with 2050 additions and 171 deletions

View File

@@ -23,6 +23,7 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr
- [Commands](#commands)
- [Browser Automation](#browser-automation)
- [Tmux Integration](#tmux-integration)
- [Cmux Integration](#cmux-integration)
- [Git Master](#git-master)
- [Comment Checker](#comment-checker)
- [Notification](#notification)
@@ -565,6 +566,61 @@ Run background subagents in separate tmux panes. Requires running inside tmux wi
| `main_pane_min_width` | `120` | Min main pane columns |
| `agent_pane_min_width` | `40` | Min agent pane columns |
### Cmux Integration
Cmux integration provides notification routing when running inside a cmux workspace. The plugin probes for cmux availability at runtime and selects a notification backend based on live capability detection.
#### Runtime Model: ResolvedMultiplexer
The runtime evaluates tmux and cmux availability to determine operating mode:
| Mode | Conditions | Pane Control | Notifications |
| ------------------ | ---------------------------------------------- | ------------ | ------------- |
| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux |
| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop |
| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux |
| `none` | Neither tmux nor cmux available | none | desktop |
#### Backend Precedence Semantics
**Pane Backend**: Tmux is used for pane control when available. Cmux provides notifications only; it does not manage panes.
**Notification Backend**:
- Cmux-first when the runtime detects a live, notification-capable cmux endpoint
- Silent fallback to desktop notifications on any failure (non-zero exit, timeout, connection refused)
- Once downgraded to desktop, cmux notifications remain disabled for the session
#### Detection and Probing
The runtime probes cmux availability using these signals:
1. **Binary discovery**: Locates `cmux` executable in PATH
2. **Socket path resolution**: Reads `CMUX_SOCKET_PATH` environment variable (unix socket or relay endpoint)
3. **Reachability probe**: Executes `cmux ping` against the resolved endpoint (250ms timeout)
4. **Capability gating**: Executes `cmux notify --help` to verify notification support (300ms timeout)
**Endpoint types**: Unix domain sockets (`/tmp/cmux.sock`) and relay addresses (`host:port`) are both supported.
**Hint strength**: Strong hints (both `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` present) preserve cmux-shim mode even in nested tmux environments. Weak hints (e.g., `TERM_PROGRAM=ghostty`) are tolerated but do not override failed probes.
#### Environment Variables
| Variable | Description |
| ------------------------------- | ----------------------------------------------------- |
| `CMUX_SOCKET_PATH` | Path to cmux socket (unix) or relay endpoint (host:port) |
| `CMUX_WORKSPACE_ID` | Cmux workspace identifier |
| `CMUX_SURFACE_ID` | Cmux surface identifier |
| `OH_MY_OPENCODE_DISABLE_CMUX` | Set to `1` or `true` to disable cmux integration |
| `OH_MY_OPENCODE_DISABLE_TMUX` | Set to `1` or `true` to disable tmux integration |
#### Behavior Boundaries
- **Notifications**: Delivered via `cmux notify` when a live cmux endpoint is detected
- **Pane Control**: Tmux manages panes. Cmux does not create or control panes.
- **Guarantee**: Tmux-compatible pane control remains available when tmux is live
The `interactive_bash` tool always uses tmux subcommands for pane operations, regardless of cmux availability.
### Git Master
Configure git commit behavior:
@@ -970,9 +1026,14 @@ When enabled, two companion hooks are active: `hashline-read-enhancer` (annotate
### Environment Variables
| Variable | Description |
| --------------------- | ----------------------------------------------------------------- |
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) |
| Variable | Description |
| ------------------------------ | --------------------------------------------------------------------------- |
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) |
| `CMUX_SOCKET_PATH` | Path to cmux socket (unix) or relay endpoint (host:port) for cmux integration |
| `CMUX_WORKSPACE_ID` | Cmux workspace identifier (enables strong cmux hints) |
| `CMUX_SURFACE_ID` | Cmux surface identifier (enables strong cmux hints) |
| `OH_MY_OPENCODE_DISABLE_CMUX` | Set to `1` or `true` to disable cmux integration |
| `OH_MY_OPENCODE_DISABLE_TMUX` | Set to `1` or `true` to disable tmux integration |
### Provider-Specific

View File

@@ -4,6 +4,7 @@ import type { LoadedSkill } from "./features/opencode-skill-loader/types"
import type { BackgroundManager } from "./features/background-agent"
import type { PluginContext } from "./plugin/types"
import type { ModelCacheState } from "./plugin-state"
import type { ResolvedMultiplexer } from "./shared/tmux"
import { createCoreHooks } from "./plugin/hooks/create-core-hooks"
import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks"
@@ -34,6 +35,7 @@ export function createHooks(args: {
safeHookEnabled: boolean
mergedSkills: LoadedSkill[]
availableSkills: AvailableSkill[]
resolvedMultiplexer: ResolvedMultiplexer
}) {
const {
ctx,
@@ -44,6 +46,7 @@ export function createHooks(args: {
safeHookEnabled,
mergedSkills,
availableSkills,
resolvedMultiplexer,
} = args
const core = createCoreHooks({
@@ -52,6 +55,7 @@ export function createHooks(args: {
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
})
const continuation = createContinuationHooks({

View File

@@ -10,9 +10,11 @@ import { TmuxSessionManager } from "./features/tmux-subagent"
import { registerManagerForCleanup } from "./features/background-agent/process-cleanup"
import { createConfigHandler } from "./plugin-handlers"
import { log } from "./shared"
import type { ResolvedMultiplexer } from "./shared/tmux"
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
export type Managers = {
resolvedMultiplexer: ResolvedMultiplexer
tmuxSessionManager: TmuxSessionManager
backgroundManager: BackgroundManager
skillMcpManager: SkillMcpManager
@@ -23,13 +25,21 @@ export function createManagers(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
tmuxConfig: TmuxConfig
resolvedMultiplexer: ResolvedMultiplexer
modelCacheState: ModelCacheState
backgroundNotificationHookEnabled: boolean
}): Managers {
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args
const {
ctx,
pluginConfig,
tmuxConfig,
resolvedMultiplexer,
modelCacheState,
backgroundNotificationHookEnabled,
} = args
markServerRunningInProcess()
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, resolvedMultiplexer)
registerManagerForCleanup({
shutdown: async () => {
@@ -44,13 +54,18 @@ export function createManagers(args: {
pluginConfig.background_task,
{
tmuxConfig,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
resolvedMultiplexer,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
})
if (resolvedMultiplexer.paneBackend !== "tmux") {
return
}
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
@@ -84,6 +99,7 @@ export function createManagers(args: {
})
return {
resolvedMultiplexer,
tmuxSessionManager,
backgroundManager,
skillMcpManager,

View File

@@ -22,7 +22,7 @@ export type CreateToolsResult = {
export async function createTools(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
}): Promise<CreateToolsResult> {
const { ctx, pluginConfig, managers } = args

View File

@@ -20,7 +20,11 @@ import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
} from "../../shared/tmux"
import type { ResolvedMultiplexer } from "../../shared/tmux"
import {
shouldRetryError,
hasMoreFallbacks,
@@ -141,6 +145,7 @@ export class BackgroundManager {
private shutdownTriggered = false
private config?: BackgroundTaskConfig
private tmuxEnabled: boolean
private resolvedMultiplexer: ResolvedMultiplexer
private onSubagentSessionCreated?: OnSubagentSessionCreated
private onShutdown?: () => void | Promise<void>
@@ -161,6 +166,7 @@ export class BackgroundManager {
config?: BackgroundTaskConfig,
options?: {
tmuxConfig?: TmuxConfig
resolvedMultiplexer?: ResolvedMultiplexer
onSubagentSessionCreated?: OnSubagentSessionCreated
onShutdown?: () => void | Promise<void>
enableParentSessionNotifications?: boolean
@@ -175,6 +181,10 @@ export class BackgroundManager {
this.concurrencyManager = new ConcurrencyManager(config)
this.config = config
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
this.resolvedMultiplexer =
options?.resolvedMultiplexer
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
this.onShutdown = options?.onShutdown
this.rootDescendantCounts = new Map()
@@ -455,12 +465,17 @@ export class BackgroundManager {
log("[background-agent] tmux callback check", {
hasCallback: !!this.onSubagentSessionCreated,
tmuxEnabled: this.tmuxEnabled,
isInsideTmux: isInsideTmux(),
paneBackend: this.resolvedMultiplexer.paneBackend,
multiplexerMode: this.resolvedMultiplexer.mode,
sessionID,
parentID: input.parentSessionID,
})
if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
if (
this.onSubagentSessionCreated
&& this.tmuxEnabled
&& this.resolvedMultiplexer.paneBackend === "tmux"
) {
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await this.onSubagentSessionCreated({
sessionID,

View File

@@ -5,7 +5,11 @@ import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createIn
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
} from "../../shared/tmux"
import type { ResolvedMultiplexer } from "../../shared/tmux"
import type { ConcurrencyManager } from "./concurrency"
export interface SpawnerContext {
@@ -13,6 +17,7 @@ export interface SpawnerContext {
directory: string
concurrencyManager: ConcurrencyManager
tmuxEnabled: boolean
resolvedMultiplexer?: ResolvedMultiplexer
onSubagentSessionCreated?: OnSubagentSessionCreated
onTaskError: (task: BackgroundTask, error: Error) => void
}
@@ -38,7 +43,19 @@ export async function startTask(
ctx: SpawnerContext
): Promise<void> {
const { task, input } = item
const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx
const {
client,
directory,
concurrencyManager,
tmuxEnabled,
resolvedMultiplexer,
onSubagentSessionCreated,
onTaskError,
} = ctx
const multiplexerRuntime =
resolvedMultiplexer
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
log("[background-agent] Starting task:", {
taskId: task.id,
@@ -83,12 +100,17 @@ export async function startTask(
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
paneBackend: multiplexerRuntime.paneBackend,
multiplexerMode: multiplexerRuntime.mode,
sessionID,
parentID: input.parentSessionID,
})
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
if (
onSubagentSessionCreated
&& tmuxEnabled
&& multiplexerRuntime.paneBackend === "tmux"
) {
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,

View File

@@ -3,12 +3,15 @@ import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
import { log, normalizeSDKResponse } from "../../shared"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS,
SESSION_READY_POLL_INTERVAL_MS,
SESSION_READY_TIMEOUT_MS,
} from "../../shared/tmux"
import type { ResolvedMultiplexer } from "../../shared/tmux"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeActions, executeAction } from "./action-executor"
@@ -32,6 +35,38 @@ export interface TmuxUtilDeps {
getCurrentPaneId: () => string | undefined
}
function isTmuxUtilDeps(value: unknown): value is TmuxUtilDeps {
if (!value || typeof value !== "object") {
return false
}
const candidate = value as Partial<TmuxUtilDeps>
return (
typeof candidate.isInsideTmux === "function"
&& typeof candidate.getCurrentPaneId === "function"
)
}
function createRuntimeFromLegacyDeps(deps: TmuxUtilDeps): ResolvedMultiplexer {
const runtime = createDisabledMultiplexerRuntime()
const insideTmux = deps.isInsideTmux()
if (!insideTmux) {
return runtime
}
return {
...runtime,
mode: "tmux-only",
paneBackend: "tmux",
tmux: {
...runtime.tmux,
reachable: true,
insideEnvironment: true,
paneId: deps.getCurrentPaneId(),
},
}
}
const defaultTmuxDeps: TmuxUtilDeps = {
isInsideTmux: defaultIsInsideTmux,
getCurrentPaneId: defaultGetCurrentPaneId,
@@ -67,11 +102,25 @@ export class TmuxSessionManager {
private deferredAttachTickScheduled = false
private nullStateCount = 0
private deps: TmuxUtilDeps
private resolvedMultiplexer: ResolvedMultiplexer
private pollingManager: TmuxPollingManager
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
constructor(
ctx: PluginInput,
tmuxConfig: TmuxConfig,
runtimeOrDeps: ResolvedMultiplexer | TmuxUtilDeps = createDisabledMultiplexerRuntime(),
deps: TmuxUtilDeps = defaultTmuxDeps,
) {
this.client = ctx.client
this.tmuxConfig = tmuxConfig
this.deps = deps
if (isTmuxUtilDeps(runtimeOrDeps)) {
this.deps = runtimeOrDeps
this.resolvedMultiplexer = getResolvedMultiplexerRuntime() ?? createRuntimeFromLegacyDeps(runtimeOrDeps)
} else {
this.deps = deps
this.resolvedMultiplexer = runtimeOrDeps
}
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
const fallbackUrl = `http://localhost:${defaultPort}`
try {
@@ -86,7 +135,7 @@ export class TmuxSessionManager {
} catch {
this.serverUrl = fallbackUrl
}
this.sourcePaneId = deps.getCurrentPaneId()
this.sourcePaneId = this.resolvedMultiplexer.tmux.paneId ?? this.deps.getCurrentPaneId()
this.pollingManager = new TmuxPollingManager(
this.client,
this.sessions,
@@ -97,10 +146,12 @@ export class TmuxSessionManager {
tmuxConfig: this.tmuxConfig,
serverUrl: this.serverUrl,
sourcePaneId: this.sourcePaneId,
multiplexerMode: this.resolvedMultiplexer.mode,
paneBackend: this.resolvedMultiplexer.paneBackend,
})
}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
return this.tmuxConfig.enabled && this.resolvedMultiplexer.paneBackend === "tmux"
}
private getCapacityConfig(): CapacityConfig {
@@ -440,7 +491,8 @@ export class TmuxSessionManager {
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: this.tmuxConfig.enabled,
isInsideTmux: this.deps.isInsideTmux(),
isInsideTmux: this.resolvedMultiplexer.paneBackend === "tmux",
multiplexerMode: this.resolvedMultiplexer.mode,
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,

View File

@@ -0,0 +1,142 @@
import { describe, expect, test } from "bun:test"
import {
createCmuxNotificationAdapter,
type CmuxNotifyCommandResult,
} from "./cmux-notification-adapter"
import type { ResolvedMultiplexer } from "../shared/tmux"
function createResolvedMultiplexer(): ResolvedMultiplexer {
return {
platform: "darwin",
mode: "cmux-shim",
paneBackend: "tmux",
notificationBackend: "cmux",
tmux: {
path: "/usr/bin/tmux",
reachable: true,
insideEnvironment: true,
paneId: "%1",
explicitDisable: false,
},
cmux: {
path: "/usr/local/bin/cmux",
reachable: true,
notifyCapable: true,
socketPath: "/tmp/cmux.sock",
endpointType: "unix",
workspaceId: "workspace-1",
surfaceId: "surface-1",
hintStrength: "strong",
explicitDisable: false,
},
}
}
function createResult(overrides: Partial<CmuxNotifyCommandResult> = {}): CmuxNotifyCommandResult {
return {
exitCode: 0,
stdout: "",
stderr: "",
timedOut: false,
...overrides,
}
}
describe("cmux notification adapter", () => {
test("delivers via cmux when notify command succeeds", async () => {
let callCount = 0
let receivedArgs: string[] = []
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async (input) => {
callCount += 1
receivedArgs = input.args
return createResult()
},
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(true)
expect(callCount).toBe(1)
expect(receivedArgs).toEqual([
"/usr/local/bin/cmux",
"notify",
"--title",
"OpenCode",
"--body",
"Task complete",
"--workspace",
"workspace-1",
"--surface",
"surface-1",
])
expect(adapter.hasDowngraded()).toBe(false)
})
test("falls back to desktop when cmux notify exits non-zero", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 2,
stderr: "notify failed",
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("falls back to desktop when cmux notify times out", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
timedOut: true,
exitCode: null,
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("falls back to desktop on connection-refused failures", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 1,
stderr: "dial tcp 127.0.0.1:7777: connect: connection refused",
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("downgrades permanently after first cmux notify failure", async () => {
let callCount = 0
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => {
callCount += 1
return createResult({
exitCode: 1,
stderr: "notify failed",
})
},
})
const firstDelivered = await adapter.send("OpenCode", "First")
const secondDelivered = await adapter.send("OpenCode", "Second")
expect(firstDelivered).toBe(false)
expect(secondDelivered).toBe(false)
expect(callCount).toBe(1)
})
})

View File

@@ -0,0 +1,189 @@
import { spawn } from "bun"
import type { ResolvedMultiplexer } from "../shared/tmux"
import { isConnectionRefusedText } from "../tools/interactive-bash/tmux-path-resolver"
const DEFAULT_NOTIFY_TIMEOUT_MS = 1200
export interface CmuxNotifyCommandResult {
exitCode: number | null
stdout: string
stderr: string
timedOut: boolean
}
export type CmuxNotifyCommandExecutor = (input: {
args: string[]
environment: Record<string, string>
timeoutMs: number
}) => Promise<CmuxNotifyCommandResult>
export interface CmuxNotificationAdapter {
canSendViaCmux: () => boolean
hasDowngraded: () => boolean
send: (title: string, message: string) => Promise<boolean>
}
function toCommandEnvironment(
runtime: ResolvedMultiplexer,
environment: Record<string, string | undefined>,
): Record<string, string> {
const merged: Record<string, string | undefined> = {
...process.env,
...environment,
}
if (runtime.cmux.socketPath) {
merged.CMUX_SOCKET_PATH = runtime.cmux.socketPath
}
if (runtime.cmux.workspaceId) {
merged.CMUX_WORKSPACE_ID = runtime.cmux.workspaceId
}
if (runtime.cmux.surfaceId) {
merged.CMUX_SURFACE_ID = runtime.cmux.surfaceId
}
const commandEnvironment: Record<string, string> = {}
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
commandEnvironment[key] = value
}
}
return commandEnvironment
}
async function runCmuxNotifyCommand(input: {
args: string[]
environment: Record<string, string>
timeoutMs: number
}): Promise<CmuxNotifyCommandResult> {
const proc = spawn(input.args, {
stdout: "pipe",
stderr: "pipe",
env: input.environment,
})
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const timedOut = await Promise.race([
proc.exited.then(() => false).catch(() => false),
new Promise<boolean>((resolve) => {
timeoutHandle = setTimeout(() => {
try {
proc.kill()
} catch {
// ignore
}
resolve(true)
}, input.timeoutMs)
}),
])
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
const exitCode = timedOut ? null : await proc.exited.catch(() => null)
const stdout = await new Response(proc.stdout).text().catch(() => "")
const stderr = await new Response(proc.stderr).text().catch(() => "")
return {
exitCode,
stdout,
stderr,
timedOut,
}
}
function buildCmuxNotifyArgs(runtime: ResolvedMultiplexer, title: string, message: string): string[] {
const cmuxPath = runtime.cmux.path ?? "cmux"
const args: string[] = [cmuxPath, "notify", "--title", title, "--body", message]
if (runtime.cmux.workspaceId) {
args.push("--workspace", runtime.cmux.workspaceId)
}
if (runtime.cmux.surfaceId) {
args.push("--surface", runtime.cmux.surfaceId)
}
return args
}
function shouldDowngrade(result: CmuxNotifyCommandResult): boolean {
if (result.timedOut) {
return true
}
if (result.exitCode === 0) {
return false
}
const combinedOutput = `${result.stderr}\n${result.stdout}`
if (isConnectionRefusedText(combinedOutput)) {
return true
}
return true
}
export function createCmuxNotificationAdapter(args: {
runtime: ResolvedMultiplexer
environment?: Record<string, string | undefined>
timeoutMs?: number
executeCommand?: CmuxNotifyCommandExecutor
}): CmuxNotificationAdapter {
const {
runtime,
environment = process.env,
timeoutMs = DEFAULT_NOTIFY_TIMEOUT_MS,
executeCommand = runCmuxNotifyCommand,
} = args
let downgradedToDesktop = false
const canSendViaCmux = (): boolean => {
if (downgradedToDesktop) return false
if (runtime.notificationBackend !== "cmux") return false
if (!runtime.cmux.path) return false
if (!runtime.cmux.socketPath) return false
if (!runtime.cmux.reachable) return false
if (!runtime.cmux.notifyCapable) return false
return true
}
const hasDowngraded = (): boolean => downgradedToDesktop
const send = async (title: string, message: string): Promise<boolean> => {
if (!canSendViaCmux()) {
return false
}
const commandEnvironment = toCommandEnvironment(runtime, environment)
const commandResult = await executeCommand({
args: buildCmuxNotifyArgs(runtime, title, message),
environment: commandEnvironment,
timeoutMs,
}).catch(() => {
downgradedToDesktop = true
return null
})
if (!commandResult) {
return false
}
if (shouldDowngrade(commandResult)) {
downgradedToDesktop = true
return false
}
return true
}
return {
canSendViaCmux,
hasDowngraded,
send,
}
}

View File

@@ -3,6 +3,8 @@ import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils"
import * as sender from "./session-notification-sender"
import type { ResolvedMultiplexer } from "../shared/tmux"
import type { CmuxNotificationAdapter } from "./cmux-notification-adapter"
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
@@ -33,6 +35,42 @@ describe("session-notification", () => {
} as any
}
function createCmuxRuntime(): ResolvedMultiplexer {
return {
platform: "darwin",
mode: "cmux-shim",
paneBackend: "tmux",
notificationBackend: "cmux",
tmux: {
path: "/usr/bin/tmux",
reachable: true,
insideEnvironment: true,
paneId: "%1",
explicitDisable: false,
},
cmux: {
path: "/usr/local/bin/cmux",
reachable: true,
notifyCapable: true,
socketPath: "/tmp/cmux.sock",
endpointType: "unix",
workspaceId: "workspace-1",
surfaceId: "surface-1",
hintStrength: "strong",
explicitDisable: false,
},
}
}
function createCmuxAdapter(overrides: Partial<CmuxNotificationAdapter> = {}): CmuxNotificationAdapter {
return {
canSendViaCmux: () => true,
hasDowngraded: () => false,
send: async () => false,
...overrides,
}
}
beforeEach(() => {
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
@@ -387,6 +425,126 @@ describe("session-notification", () => {
expect(notificationCalls).toHaveLength(1)
})
test("routes idle notifications through cmux without desktop fallback when cmux send succeeds", async () => {
const sessionID = "cmux-success"
let cmuxSendCalls = 0
const cmuxAdapter = createCmuxAdapter({
send: async () => {
cmuxSendCalls += 1
return true
},
})
const hook = createSessionNotification(
createMockPluginInput(),
{
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
},
{
resolvedMultiplexer: createCmuxRuntime(),
cmuxNotificationAdapter: cmuxAdapter,
},
)
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(1)
expect(notificationCalls).toHaveLength(0)
})
test("falls back to desktop notification when cmux send fails", async () => {
const sessionID = "cmux-fallback"
let cmuxSendCalls = 0
const cmuxAdapter = createCmuxAdapter({
send: async () => {
cmuxSendCalls += 1
return false
},
})
const hook = createSessionNotification(
createMockPluginInput(),
{
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
},
{
resolvedMultiplexer: createCmuxRuntime(),
cmuxNotificationAdapter: cmuxAdapter,
},
)
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(1)
expect(notificationCalls).toHaveLength(1)
})
test("suppresses duplicate idle notifications while using cmux backend", async () => {
const sessionID = "cmux-duplicate"
let cmuxSendCalls = 0
const cmuxAdapter = createCmuxAdapter({
send: async () => {
cmuxSendCalls += 1
return true
},
})
const hook = createSessionNotification(
createMockPluginInput(),
{
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
},
{
resolvedMultiplexer: createCmuxRuntime(),
cmuxNotificationAdapter: cmuxAdapter,
},
)
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(1)
expect(notificationCalls).toHaveLength(0)
})
function createSenderMockCtx() {
const notifyCalls: string[] = []
const mockCtx = {

View File

@@ -10,6 +10,15 @@ import {
import * as sessionNotificationSender from "./session-notification-sender"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../shared/tmux"
import {
createCmuxNotificationAdapter,
type CmuxNotificationAdapter,
} from "./cmux-notification-adapter"
interface SessionNotificationConfig {
title?: string
@@ -30,10 +39,24 @@ interface SessionNotificationConfig {
}
export function createSessionNotification(
ctx: PluginInput,
config: SessionNotificationConfig = {}
config: SessionNotificationConfig = {},
options: {
resolvedMultiplexer?: ResolvedMultiplexer
cmuxNotificationAdapter?: CmuxNotificationAdapter
} = {},
) {
const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
const resolvedMultiplexer =
options.resolvedMultiplexer
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
const cmuxNotificationAdapter =
options.cmuxNotificationAdapter
?? createCmuxNotificationAdapter({
runtime: resolvedMultiplexer,
environment: process.env,
})
startBackgroundCheck(currentPlatform)
@@ -61,12 +84,18 @@ export function createSessionNotification(
typeof hookCtx.client.session.get !== "function"
&& typeof hookCtx.client.session.messages !== "function"
) {
await sessionNotificationSender.sendSessionNotification(
hookCtx,
platform,
const deliveredViaCmux = await cmuxNotificationAdapter.send(
mergedConfig.title,
mergedConfig.message,
)
if (!deliveredViaCmux && platform !== "unsupported") {
await sessionNotificationSender.sendSessionNotification(
hookCtx,
platform,
mergedConfig.title,
mergedConfig.message,
)
}
return
}
@@ -76,6 +105,15 @@ export function createSessionNotification(
baseMessage: mergedConfig.message,
})
const deliveredViaCmux = await cmuxNotificationAdapter.send(content.title, content.message)
if (deliveredViaCmux) {
return
}
if (platform === "unsupported") {
return
}
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
},
playSound: sessionNotificationSender.playSessionNotificationSound,
@@ -134,7 +172,7 @@ export function createSessionNotification(
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
if (currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()) return
const props = event.properties as Record<string, unknown> | undefined
@@ -172,12 +210,18 @@ export function createSessionNotification(
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
const deliveredViaCmux = await cmuxNotificationAdapter.send(
mergedConfig.title,
mergedConfig.permissionMessage,
)
if (!deliveredViaCmux && currentPlatform !== "unsupported") {
await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
mergedConfig.title,
mergedConfig.permissionMessage,
)
}
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
@@ -199,7 +243,10 @@ export function createSessionNotification(
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
const deliveredViaCmux = await cmuxNotificationAdapter.send(mergedConfig.title, message)
if (!deliveredViaCmux && currentPlatform !== "unsupported") {
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
}
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}

View File

@@ -12,9 +12,14 @@ import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
import {
injectServerAuthIntoClient,
log,
logLegacyPluginStartupWarning,
resolveMultiplexerRuntime,
setResolvedMultiplexerRuntime,
} from "./shared"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startTmuxCheck } from "./tools"
let activePluginDispose: PluginDispose | null = null
@@ -33,7 +38,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}
injectServerAuthIntoClient(ctx.client)
startTmuxCheck()
await activePluginDispose?.()
const pluginConfig = loadPluginConfig(ctx.directory, ctx)
@@ -54,10 +58,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const modelCacheState = createModelCacheState()
const resolvedMultiplexer = await resolveMultiplexerRuntime({
environment: process.env,
tmuxEnabled: tmuxConfig.enabled,
})
setResolvedMultiplexerRuntime(resolvedMultiplexer)
const managers = createManagers({
ctx,
pluginConfig,
tmuxConfig,
resolvedMultiplexer,
modelCacheState,
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
})
@@ -77,6 +88,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
resolvedMultiplexer,
})
const dispose = createPluginDispose({

View File

@@ -1,16 +1,35 @@
import { spawn } from "bun"
import { getTmuxPath } from "../tools/interactive-bash/tmux-path-resolver"
import {
getCurrentPaneId,
getResolvedMultiplexerRuntime,
isInsideTmux,
} from "../shared/tmux"
export function getCurrentTmuxSession(): string | null {
const env = process.env.TMUX
if (!env) return null
const match = env.match(/(\d+)$/)
return match ? `session-${match[1]}` : null // Wait, TMUX env is /tmp/tmux-501/default,1234,0
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
return null
}
if (!isInsideTmux(resolvedMultiplexer ?? undefined)) {
return 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'`
}
export async function getTmuxSessionName(): Promise<string | null> {
try {
const proc = spawn(["tmux", "display-message", "-p", "#S"], {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return null
const proc = spawn([tmuxPath, "display-message", "-p", "#S"], {
stdout: "pipe",
stderr: "ignore",
})
@@ -27,8 +46,11 @@ export async function getTmuxSessionName(): Promise<string | null> {
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
try {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return null
const proc = spawn(
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
[tmuxPath, "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
{
stdout: "pipe",
stderr: "ignore",
@@ -46,7 +68,10 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
try {
const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return false
const literalProc = spawn([tmuxPath, "send-keys", "-t", paneId, "-l", "--", text], {
stdout: "ignore",
stderr: "ignore",
})
@@ -55,7 +80,7 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
if (!confirm) return true
const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], {
const enterProc = spawn([tmuxPath, "send-keys", "-t", paneId, "Enter"], {
stdout: "ignore",
stderr: "ignore",
})
@@ -67,8 +92,16 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
}
export async function isTmuxAvailable(): Promise<boolean> {
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
return false
}
try {
const proc = spawn(["tmux", "-V"], {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return false
const proc = spawn([tmuxPath, "-V"], {
stdout: "ignore",
stderr: "ignore",
})

View File

@@ -341,14 +341,16 @@ export function createEventHandler(args: {
firstMessageVariantGate.markSessionCreated(sessionInfo);
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
}
}
if (event.type === "session.deleted") {
@@ -376,9 +378,11 @@ export function createEventHandler(args: {
deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients();
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
});
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
});
}
}
}

View File

@@ -1,6 +1,7 @@
import type { HookName, OhMyOpenCodeConfig } from "../../config"
import type { PluginContext } from "../types"
import type { ModelCacheState } from "../../plugin-state"
import type { ResolvedMultiplexer } from "../../shared/tmux"
import { createSessionHooks } from "./create-session-hooks"
import { createToolGuardHooks } from "./create-tool-guard-hooks"
@@ -12,8 +13,16 @@ export function createCoreHooks(args: {
modelCacheState: ModelCacheState
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
resolvedMultiplexer: ResolvedMultiplexer
}) {
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
const {
ctx,
pluginConfig,
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
} = args
const session = createSessionHooks({
ctx,
@@ -21,6 +30,7 @@ export function createCoreHooks(args: {
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
})
const tool = createToolGuardHooks({

View File

@@ -1,6 +1,7 @@
import type { OhMyOpenCodeConfig, HookName } from "../../config"
import type { ModelCacheState } from "../../plugin-state"
import type { PluginContext } from "../types"
import type { ResolvedMultiplexer } from "../../shared/tmux"
import {
createContextWindowMonitorHook,
@@ -70,8 +71,16 @@ export function createSessionHooks(args: {
modelCacheState: ModelCacheState
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
resolvedMultiplexer: ResolvedMultiplexer
}): SessionHooks {
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
const {
ctx,
pluginConfig,
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
} = args
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
@@ -99,7 +108,10 @@ export function createSessionHooks(args: {
if (externalNotifier.detected && !forceEnable) {
log(getNotificationConflictWarning(externalNotifier.pluginName!))
} else {
sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx))
sessionNotification = safeHook("session-notification", () =>
createSessionNotification(ctx, {}, {
resolvedMultiplexer,
}))
}
}

View File

@@ -20,7 +20,7 @@ import {
createSessionManagerTools,
createDelegateTask,
discoverCommandsSync,
interactive_bash,
createInteractiveBashTool,
createTaskCreateTool,
createTaskGetTool,
createTaskList,
@@ -100,7 +100,7 @@ function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
export function createToolRegistry(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
skillContext: SkillContext
availableCategories: AvailableCategory[]
}): ToolRegistryResult {
@@ -134,6 +134,10 @@ export function createToolRegistry(args: {
availableSkills: skillContext.availableSkills,
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
onSyncSessionCreated: async (event) => {
if (managers.resolvedMultiplexer.paneBackend !== "tmux") {
return
}
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
parentID: event.parentID,
@@ -202,7 +206,7 @@ export function createToolRegistry(args: {
task: delegateTask,
skill_mcp: skillMcpTool,
skill: skillTool,
interactive_bash,
interactive_bash: createInteractiveBashTool(managers.resolvedMultiplexer),
...taskToolsRecord,
...hashlineToolsRecord,
}

View File

@@ -1,6 +1,28 @@
export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment"
export {
isInsideTmux,
getCurrentPaneId,
isTmuxPaneControlAvailable,
} from "./tmux-utils/environment"
export type { SplitDirection } from "./tmux-utils/environment"
export {
resolveMultiplexerRuntime,
resolveMultiplexerFromProbes,
createDisabledMultiplexerRuntime,
setResolvedMultiplexerRuntime,
getResolvedMultiplexerRuntime,
resetResolvedMultiplexerRuntimeForTesting,
} from "./tmux-utils/multiplexer-runtime"
export type {
MultiplexerMode,
PaneBackend,
NotificationBackend,
ResolvedTmuxRuntime,
ResolvedCmuxRuntime,
ResolvedMultiplexer,
ResolveMultiplexerRuntimeOptions,
} from "./tmux-utils/multiplexer-runtime"
export { isServerRunning, resetServerCheck, markServerRunningInProcess } from "./tmux-utils/server-health"
export { getPaneDimensions } from "./tmux-utils/pane-dimensions"

View File

@@ -1,13 +1,37 @@
import type { ResolvedMultiplexer } from "./multiplexer-runtime"
import { getResolvedMultiplexerRuntime } from "./multiplexer-runtime"
export type SplitDirection = "-h" | "-v"
function resolveRuntime(runtime: ResolvedMultiplexer | undefined): ResolvedMultiplexer | null {
return runtime ?? getResolvedMultiplexerRuntime()
}
export function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
return Boolean(environment.TMUX)
}
export function isInsideTmux(): boolean {
return isInsideTmuxEnvironment(process.env)
export function isInsideTmux(runtime?: ResolvedMultiplexer): boolean {
const resolvedRuntime = resolveRuntime(runtime)
if (resolvedRuntime) {
return resolvedRuntime.paneBackend === "tmux"
}
return isInsideTmuxEnvironment(process.env)
}
export function getCurrentPaneId(): string | undefined {
return process.env.TMUX_PANE
export function getCurrentPaneId(runtime?: ResolvedMultiplexer): string | undefined {
const resolvedRuntime = resolveRuntime(runtime)
if (resolvedRuntime) {
if (resolvedRuntime.paneBackend !== "tmux") {
return undefined
}
return resolvedRuntime.tmux.paneId
}
return process.env.TMUX_PANE
}
export function isTmuxPaneControlAvailable(runtime?: ResolvedMultiplexer): boolean {
return isInsideTmux(runtime)
}

View File

@@ -0,0 +1,237 @@
import { describe, expect, test } from "bun:test"
import {
createDisabledMultiplexerRuntime,
resolveMultiplexerFromProbes,
type ResolvedMultiplexer,
} from "./multiplexer-runtime"
import type { CmuxRuntimeProbe, TmuxRuntimeProbe } from "../../../tools/interactive-bash/tmux-path-resolver"
function createTmuxProbe(overrides: Partial<TmuxRuntimeProbe> = {}): TmuxRuntimeProbe {
return {
path: "/usr/bin/tmux",
reachable: true,
paneControlReachable: true,
explicitDisable: false,
...overrides,
}
}
function createCmuxProbe(overrides: Partial<CmuxRuntimeProbe> = {}): CmuxRuntimeProbe {
return {
path: "/usr/local/bin/cmux",
socketPath: "/tmp/cmux.sock",
endpointType: "unix",
workspaceId: "workspace-1",
surfaceId: "surface-1",
hintStrength: "strong",
reachable: true,
explicitDisable: false,
notifyCapable: true,
...overrides,
}
}
function resolveRuntime(args: {
environment?: Record<string, string | undefined>
platform?: NodeJS.Platform
tmuxEnabled?: boolean
cmuxEnabled?: boolean
tmuxProbe?: Partial<TmuxRuntimeProbe>
cmuxProbe?: Partial<CmuxRuntimeProbe>
}): ResolvedMultiplexer {
return resolveMultiplexerFromProbes({
platform: args.platform ?? "darwin",
environment: {
TMUX: "/tmp/tmux-501/default,999,0",
TMUX_PANE: "%1",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
CMUX_WORKSPACE_ID: "workspace-1",
CMUX_SURFACE_ID: "surface-1",
TERM_PROGRAM: "ghostty",
...args.environment,
},
tmuxEnabled: args.tmuxEnabled ?? true,
cmuxEnabled: args.cmuxEnabled ?? true,
tmuxProbe: createTmuxProbe(args.tmuxProbe),
cmuxProbe: createCmuxProbe(args.cmuxProbe),
})
}
describe("multiplexer runtime resolution", () => {
test("resolves cmux-shim when both runtimes are live", () => {
const runtime = resolveRuntime({})
expect(runtime.mode).toBe("cmux-shim")
expect(runtime.paneBackend).toBe("tmux")
expect(runtime.notificationBackend).toBe("cmux")
})
test("resolves tmux-only when cmux is unreachable", () => {
const runtime = resolveRuntime({
cmuxProbe: {
reachable: false,
failureKind: "missing-socket",
},
})
expect(runtime.mode).toBe("tmux-only")
expect(runtime.paneBackend).toBe("tmux")
expect(runtime.notificationBackend).toBe("desktop")
})
test("resolves cmux-notify-only when tmux pane control is unavailable", () => {
const runtime = resolveRuntime({
tmuxProbe: {
paneControlReachable: false,
},
})
expect(runtime.mode).toBe("cmux-notify-only")
expect(runtime.paneBackend).toBe("none")
expect(runtime.notificationBackend).toBe("cmux")
})
test("resolves none when both runtimes are unavailable", () => {
const runtime = resolveRuntime({
environment: {
TMUX: undefined,
TMUX_PANE: undefined,
CMUX_SOCKET_PATH: undefined,
},
tmuxProbe: {
reachable: false,
paneControlReachable: false,
path: null,
},
cmuxProbe: {
reachable: false,
path: null,
socketPath: undefined,
endpointType: "missing",
hintStrength: "none",
notifyCapable: false,
},
})
expect(runtime.mode).toBe("none")
expect(runtime.paneBackend).toBe("none")
expect(runtime.notificationBackend).toBe("desktop")
})
test("keeps cmux-shim for nested tmux-inside-cmux hints", () => {
const runtime = resolveRuntime({
environment: {
TMUX: "/tmp/tmux-501/default,1001,0",
CMUX_SOCKET_PATH: "/tmp/cmux-nested.sock",
CMUX_WORKSPACE_ID: "workspace-nested",
CMUX_SURFACE_ID: "surface-nested",
},
})
expect(runtime.mode).toBe("cmux-shim")
expect(runtime.cmux.hintStrength).toBe("strong")
})
test("downgrades stale cmux socket env to tmux-only", () => {
const runtime = resolveRuntime({
cmuxProbe: {
reachable: false,
failureKind: "connection-refused",
},
})
expect(runtime.mode).toBe("tmux-only")
expect(runtime.notificationBackend).toBe("desktop")
})
test("respects explicit tmux disable and falls to cmux-notify-only", () => {
const runtime = resolveRuntime({
tmuxEnabled: false,
})
expect(runtime.mode).toBe("cmux-notify-only")
expect(runtime.paneBackend).toBe("none")
})
test("keeps desktop notifications when cmux is reachable without notify capability", () => {
const runtime = resolveRuntime({
cmuxProbe: {
notifyCapable: false,
notifyFailureKind: "exit-non-zero",
},
})
expect(runtime.mode).toBe("cmux-shim")
expect(runtime.notificationBackend).toBe("desktop")
})
test("treats relay endpoint addresses as valid cmux socket targets", () => {
const runtime = resolveRuntime({
environment: {
TMUX: undefined,
TMUX_PANE: undefined,
CMUX_SOCKET_PATH: "127.0.0.1:7777",
},
cmuxProbe: {
endpointType: "relay",
socketPath: "127.0.0.1:7777",
},
})
expect(runtime.mode).toBe("cmux-notify-only")
expect(runtime.cmux.endpointType).toBe("relay")
})
test("keeps weak ghostty hint as non-authoritative on non-mac platforms", () => {
const runtime = resolveRuntime({
platform: "linux",
environment: {
TMUX: undefined,
TMUX_PANE: undefined,
TERM_PROGRAM: "ghostty",
CMUX_SOCKET_PATH: undefined,
},
tmuxProbe: {
reachable: false,
paneControlReachable: false,
path: null,
},
cmuxProbe: {
reachable: false,
path: "/usr/local/bin/cmux",
socketPath: undefined,
endpointType: "missing",
hintStrength: "weak",
notifyCapable: false,
failureKind: "missing-socket",
},
})
expect(runtime.mode).toBe("none")
expect(runtime.cmux.hintStrength).toBe("weak")
expect(runtime.platform).toBe("linux")
})
test("createDisabledMultiplexerRuntime returns safe defaults", () => {
const runtime = createDisabledMultiplexerRuntime("darwin")
expect(runtime.mode).toBe("none")
expect(runtime.paneBackend).toBe("none")
expect(runtime.notificationBackend).toBe("desktop")
expect(runtime.cmux.endpointType).toBe("missing")
})
test("downgrades stale tmux environment even when tmux binary exists", () => {
const runtime = resolveRuntime({
tmuxProbe: {
reachable: true,
paneControlReachable: false,
},
})
expect(runtime.mode).toBe("cmux-notify-only")
expect(runtime.paneBackend).toBe("none")
expect(runtime.tmux.reachable).toBe(false)
expect(runtime.tmux.insideEnvironment).toBe(true)
})
})

View File

@@ -0,0 +1,204 @@
import {
probeCmuxRuntime,
probeTmuxRuntime,
type CmuxRuntimeProbe,
type CmuxEndpointType,
type CmuxHintStrength,
type TmuxRuntimeProbe,
} from "../../../tools/interactive-bash/tmux-path-resolver"
export type MultiplexerMode = "cmux-shim" | "tmux-only" | "cmux-notify-only" | "none"
export type PaneBackend = "tmux" | "none"
export type NotificationBackend = "cmux" | "desktop"
export interface ResolvedTmuxRuntime {
path: string | null
reachable: boolean
insideEnvironment: boolean
paneId: string | undefined
explicitDisable: boolean
}
export interface ResolvedCmuxRuntime {
path: string | null
reachable: boolean
notifyCapable: boolean
socketPath: string | undefined
endpointType: CmuxEndpointType
workspaceId: string | undefined
surfaceId: string | undefined
hintStrength: CmuxHintStrength
explicitDisable: boolean
}
export interface ResolvedMultiplexer {
platform: NodeJS.Platform
mode: MultiplexerMode
paneBackend: PaneBackend
notificationBackend: NotificationBackend
tmux: ResolvedTmuxRuntime
cmux: ResolvedCmuxRuntime
}
export interface ResolveMultiplexerRuntimeOptions {
environment?: Record<string, string | undefined>
platform?: NodeJS.Platform
tmuxEnabled?: boolean
cmuxEnabled?: boolean
tmuxProbe?: TmuxRuntimeProbe
cmuxProbe?: CmuxRuntimeProbe
}
function normalizeEnvValue(value: string | undefined): string | undefined {
if (value === undefined) return undefined
const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}
function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
return Boolean(normalizeEnvValue(environment.TMUX))
}
function resolveMode(input: {
hasLiveTmuxPaneControl: boolean
hasLiveCmuxRuntime: boolean
}): MultiplexerMode {
if (input.hasLiveCmuxRuntime && input.hasLiveTmuxPaneControl) {
return "cmux-shim"
}
if (input.hasLiveTmuxPaneControl) {
return "tmux-only"
}
if (input.hasLiveCmuxRuntime) {
return "cmux-notify-only"
}
return "none"
}
export function createDisabledMultiplexerRuntime(platform: NodeJS.Platform = process.platform): ResolvedMultiplexer {
return {
platform,
mode: "none",
paneBackend: "none",
notificationBackend: "desktop",
tmux: {
path: null,
reachable: false,
insideEnvironment: false,
paneId: undefined,
explicitDisable: false,
},
cmux: {
path: null,
reachable: false,
notifyCapable: false,
socketPath: undefined,
endpointType: "missing",
workspaceId: undefined,
surfaceId: undefined,
hintStrength: "none",
explicitDisable: false,
},
}
}
export function resolveMultiplexerFromProbes(args: {
platform: NodeJS.Platform
environment: Record<string, string | undefined>
tmuxEnabled: boolean
cmuxEnabled: boolean
tmuxProbe: TmuxRuntimeProbe
cmuxProbe: CmuxRuntimeProbe
}): ResolvedMultiplexer {
const insideTmux = isInsideTmuxEnvironment(args.environment)
const paneId = normalizeEnvValue(args.environment.TMUX_PANE)
const hasLiveTmuxPaneControl =
args.tmuxEnabled
&& !args.tmuxProbe.explicitDisable
&& args.tmuxProbe.paneControlReachable
&& insideTmux
const hasLiveCmuxRuntime =
args.cmuxEnabled
&& !args.cmuxProbe.explicitDisable
&& args.cmuxProbe.reachable
const mode = resolveMode({
hasLiveTmuxPaneControl,
hasLiveCmuxRuntime,
})
const paneBackend: PaneBackend = hasLiveTmuxPaneControl ? "tmux" : "none"
const notificationBackend: NotificationBackend =
hasLiveCmuxRuntime && args.cmuxProbe.notifyCapable
? "cmux"
: "desktop"
return {
platform: args.platform,
mode,
paneBackend,
notificationBackend,
tmux: {
path: args.tmuxProbe.path,
reachable: hasLiveTmuxPaneControl,
insideEnvironment: insideTmux,
paneId,
explicitDisable: args.tmuxProbe.explicitDisable,
},
cmux: {
path: args.cmuxProbe.path,
reachable: hasLiveCmuxRuntime,
notifyCapable: args.cmuxProbe.notifyCapable,
socketPath: args.cmuxProbe.socketPath,
endpointType: args.cmuxProbe.endpointType,
workspaceId: args.cmuxProbe.workspaceId,
surfaceId: args.cmuxProbe.surfaceId,
hintStrength: args.cmuxProbe.hintStrength,
explicitDisable: args.cmuxProbe.explicitDisable,
},
}
}
let resolvedMultiplexerRuntime: ResolvedMultiplexer | null = null
export async function resolveMultiplexerRuntime(
options: ResolveMultiplexerRuntimeOptions = {},
): Promise<ResolvedMultiplexer> {
const environment = options.environment ?? process.env
const platform = options.platform ?? process.platform
const tmuxEnabled = options.tmuxEnabled ?? true
const cmuxEnabled = options.cmuxEnabled ?? true
const [tmuxProbe, cmuxProbe] = await Promise.all([
options.tmuxProbe ? Promise.resolve(options.tmuxProbe) : probeTmuxRuntime({ environment }),
options.cmuxProbe ? Promise.resolve(options.cmuxProbe) : probeCmuxRuntime({ environment }),
])
const resolved = resolveMultiplexerFromProbes({
platform,
environment,
tmuxEnabled,
cmuxEnabled,
tmuxProbe,
cmuxProbe,
})
return resolved
}
export function setResolvedMultiplexerRuntime(runtime: ResolvedMultiplexer): void {
resolvedMultiplexerRuntime = runtime
}
export function getResolvedMultiplexerRuntime(): ResolvedMultiplexer | null {
return resolvedMultiplexerRuntime
}
export function resetResolvedMultiplexerRuntimeForTesting(): void {
resolvedMultiplexerRuntime = null
}

View File

@@ -19,7 +19,11 @@ export { createSessionManagerTools } from "./session-manager"
export { sessionExists } from "./session-manager/storage"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export {
interactive_bash,
createInteractiveBashTool,
startBackgroundCheck as startTmuxCheck,
} from "./interactive-bash"
export { createSkillMcpTool } from "./skill-mcp"
import {

View File

@@ -11,7 +11,7 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [
"pipep",
]
export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This is TMUX ONLY. Pass tmux subcommands directly (without 'tmux' prefix).
export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This remains TMUX ONLY in phase 1 (cmux notify does not add pane control). Pass tmux subcommands directly (without 'tmux' prefix).
Examples: new-session -d -s omo-dev, send-keys -t omo-dev "vim" Enter

View File

@@ -1,4 +1,4 @@
import { interactive_bash } from "./tools"
import { interactive_bash, createInteractiveBashTool } from "./tools"
import { startBackgroundCheck } from "./tmux-path-resolver"
export { interactive_bash, startBackgroundCheck }
export { interactive_bash, createInteractiveBashTool, startBackgroundCheck }

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import {
classifyCmuxEndpoint,
isConnectionRefusedText,
supportsCmuxNotifyFlagModel,
} from "./tmux-path-resolver"
describe("tmux-path-resolver cmux endpoint helpers", () => {
test("classifies relay host:port endpoint as relay", () => {
expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay")
})
test("classifies unix socket path as unix", () => {
expect(classifyCmuxEndpoint("/tmp/cmux.sock")).toBe("unix")
})
test("classifies empty endpoint as missing", () => {
expect(classifyCmuxEndpoint(" ")).toBe("missing")
})
test("detects connection refused text", () => {
expect(isConnectionRefusedText("connect: connection refused")).toBe(true)
expect(isConnectionRefusedText("ECONNREFUSED while connecting")).toBe(true)
expect(isConnectionRefusedText("permission denied")).toBe(false)
})
test("accepts cmux notify help output with title/body model", () => {
const help = `
cmux notify
Flags:
--title <text>
--body <text>
`
expect(supportsCmuxNotifyFlagModel(help)).toBe(true)
})
test("rejects legacy message-based notify flag model", () => {
const legacyHelp = `
cmux notify
Flags:
--title <text>
--message <text>
`
expect(supportsCmuxNotifyFlagModel(legacyHelp)).toBe(false)
})
})

View File

@@ -1,71 +1,608 @@
import { spawn } from "bun"
const DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS = 400
const DEFAULT_CMUX_PING_TIMEOUT_MS = 250
const DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS = 300
const DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS = 250
const CMUX_RELAY_ENDPOINT_PATTERN = /^[^\s/:]+:\d+$/
const TMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_TMUX"
const CMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_CMUX"
let tmuxPath: string | null = null
let initPromise: Promise<string | null> | null = null
let tmuxPathInitialized = false
let tmuxPathInitPromise: Promise<string | null> | null = null
async function findTmuxPath(): Promise<string | null> {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
let cmuxPath: string | null = null
let cmuxPathInitialized = false
let cmuxPathInitPromise: Promise<string | null> | null = null
export type CmuxEndpointType = "missing" | "unix" | "relay"
export type CmuxHintStrength = "none" | "weak" | "strong"
export type CmuxProbeFailureKind =
| "explicit-disable"
| "missing-binary"
| "missing-socket"
| "timeout"
| "connection-refused"
| "exit-non-zero"
export type CmuxNotificationCapabilityFailureKind =
| "explicit-disable"
| "missing-binary"
| "timeout"
| "unsupported-contract"
| "exit-non-zero"
interface ProbeCommandResult {
exitCode: number | null
stdout: string
stderr: string
timedOut: boolean
}
export interface ProbeOptions {
environment?: Record<string, string | undefined>
timeoutMs?: number
}
export interface TmuxRuntimeProbe {
path: string | null
reachable: boolean
paneControlReachable: boolean
explicitDisable: boolean
}
export interface CmuxReachabilityProbe {
path: string | null
socketPath: string | undefined
endpointType: CmuxEndpointType
workspaceId: string | undefined
surfaceId: string | undefined
hintStrength: CmuxHintStrength
reachable: boolean
explicitDisable: boolean
failureKind?: CmuxProbeFailureKind
}
export interface CmuxNotificationCapabilityProbe {
capable: boolean
explicitDisable: boolean
failureKind?: CmuxNotificationCapabilityFailureKind
}
export interface CmuxRuntimeProbe extends CmuxReachabilityProbe {
notifyCapable: boolean
notifyFailureKind?: CmuxNotificationCapabilityFailureKind
}
function normalizeEnvValue(value: string | undefined): string | undefined {
if (value === undefined) return undefined
const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}
function isTruthyFlag(value: string | undefined): boolean {
const normalized = normalizeEnvValue(value)
if (!normalized) return false
const lower = normalized.toLowerCase()
return lower === "1" || lower === "true" || lower === "yes" || lower === "on"
}
function toProbeEnvironment(
environment: Record<string, string | undefined> | undefined,
): Record<string, string> {
const merged: Record<string, string | undefined> = {
...process.env,
...(environment ?? {}),
}
const normalized: Record<string, string> = {}
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
normalized[key] = value
}
}
return normalized
}
export function classifyCmuxEndpoint(endpoint: string | undefined): CmuxEndpointType {
const normalized = normalizeEnvValue(endpoint)
if (!normalized) return "missing"
if (CMUX_RELAY_ENDPOINT_PATTERN.test(normalized)) {
return "relay"
}
return "unix"
}
function resolveCmuxHintStrength(environment: Record<string, string | undefined>): CmuxHintStrength {
const workspaceId = normalizeEnvValue(environment.CMUX_WORKSPACE_ID)
const surfaceId = normalizeEnvValue(environment.CMUX_SURFACE_ID)
if (workspaceId && surfaceId) {
return "strong"
}
const termProgram = normalizeEnvValue(environment.TERM_PROGRAM)
if (termProgram?.toLowerCase() === "ghostty") {
return "weak"
}
return "none"
}
export function isConnectionRefusedText(value: string): boolean {
const normalized = value.toLowerCase()
return normalized.includes("connection refused") || normalized.includes("econnrefused")
}
export function supportsCmuxNotifyFlagModel(helpText: string): boolean {
const normalized = helpText.toLowerCase()
return normalized.includes("--title") && normalized.includes("--body")
}
function buildTmuxPaneControlProbeArgs(tmuxBinary: string, paneId: string): string[] {
return [
tmuxBinary,
"display-message",
"-p",
"-t",
paneId,
"#{pane_id}",
]
}
function buildCmuxNotifyProbeArgs(input: {
cmuxBinary: string
workspaceId?: string
surfaceId?: string
}): string[] {
const args = [
input.cmuxBinary,
"notify",
"--title",
"capability-probe",
"--body",
"capability-probe",
]
if (input.workspaceId) {
args.push("--workspace", input.workspaceId)
}
if (input.surfaceId) {
args.push("--surface", input.surfaceId)
}
args.push("--help")
return args
}
async function probeTmuxPaneControl(input: {
tmuxBinary: string
paneId: string
environment: Record<string, string | undefined>
timeoutMs: number
}): Promise<boolean> {
const probeResult = await runProbeCommand(
buildTmuxPaneControlProbeArgs(input.tmuxBinary, input.paneId),
{
environment: input.environment,
timeoutMs: input.timeoutMs,
},
)
if (probeResult.timedOut || probeResult.exitCode !== 0) {
return false
}
const resolvedPaneId = normalizeEnvValue(probeResult.stdout)
return resolvedPaneId === input.paneId
}
async function runProbeCommand(
args: string[],
options: {
environment?: Record<string, string | undefined>
timeoutMs?: number
} = {},
): Promise<ProbeCommandResult> {
try {
const proc = spawn([cmd, "tmux"], {
const proc = spawn(args, {
stdout: "pipe",
stderr: "pipe",
env: toProbeEnvironment(options.environment),
})
const exitCode = await proc.exited
if (exitCode !== 0) {
return null
const timeoutMs = options.timeoutMs ?? DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const timedOut = await Promise.race([
proc.exited.then(() => false).catch(() => false),
new Promise<boolean>((resolve) => {
timeoutHandle = setTimeout(() => {
try {
proc.kill()
} catch {
// ignore
}
resolve(true)
}, timeoutMs)
}),
])
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
const stdout = await new Response(proc.stdout).text()
const path = stdout.trim().split("\n")[0]
const exitCode = timedOut ? null : await proc.exited.catch(() => null)
const stdout = await new Response(proc.stdout).text().catch(() => "")
const stderr = await new Response(proc.stderr).text().catch(() => "")
if (!path) {
return null
return {
exitCode,
stdout,
stderr,
timedOut,
}
const verifyProc = spawn([path, "-V"], {
stdout: "pipe",
stderr: "pipe",
})
const verifyExitCode = await verifyProc.exited
if (verifyExitCode !== 0) {
return null
} catch {
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: false,
}
}
}
return path
function findCommandPath(commandName: string): string | null {
try {
const discovered = Bun.which(commandName)
return discovered ?? null
} catch {
return null
}
}
async function resolveExecutablePath(commandName: string, verifyArgs: string[]): Promise<string | null> {
const discovered = findCommandPath(commandName)
if (!discovered) {
return null
}
const verification = await runProbeCommand([discovered, ...verifyArgs])
if (verification.timedOut || verification.exitCode !== 0) {
return null
}
return discovered
}
async function findTmuxPath(): Promise<string | null> {
if (isTruthyFlag(process.env[TMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("tmux", ["-V"])
}
async function findCmuxPath(): Promise<string | null> {
if (isTruthyFlag(process.env[CMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("cmux", ["--help"])
}
export async function getTmuxPath(): Promise<string | null> {
if (tmuxPath !== null) {
if (tmuxPathInitialized) {
return tmuxPath
}
if (initPromise) {
return initPromise
if (tmuxPathInitPromise) {
return tmuxPathInitPromise
}
initPromise = (async () => {
tmuxPathInitPromise = (async () => {
const path = await findTmuxPath()
tmuxPath = path
tmuxPathInitialized = true
return path
})()
return initPromise
return tmuxPathInitPromise
}
export function getCachedTmuxPath(): string | null {
return tmuxPath
}
export function startBackgroundCheck(): void {
if (!initPromise) {
initPromise = getTmuxPath()
initPromise.catch(() => {})
export async function getCmuxPath(): Promise<string | null> {
if (cmuxPathInitialized) {
return cmuxPath
}
if (cmuxPathInitPromise) {
return cmuxPathInitPromise
}
cmuxPathInitPromise = (async () => {
const path = await findCmuxPath()
cmuxPath = path
cmuxPathInitialized = true
return path
})()
return cmuxPathInitPromise
}
export function getCachedCmuxPath(): string | null {
return cmuxPath
}
export async function probeTmuxRuntime(options: ProbeOptions = {}): Promise<TmuxRuntimeProbe> {
const environment = options.environment ?? process.env
if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) {
return {
path: null,
reachable: false,
paneControlReachable: false,
explicitDisable: true,
}
}
const path = await getTmuxPath()
const paneId = normalizeEnvValue(environment.TMUX_PANE)
const hasTmuxEnvironment = Boolean(normalizeEnvValue(environment.TMUX))
if (!path || !hasTmuxEnvironment || !paneId) {
return {
path,
reachable: Boolean(path),
paneControlReachable: false,
explicitDisable: false,
}
}
const paneControlReachable = await probeTmuxPaneControl({
tmuxBinary: path,
paneId,
environment,
timeoutMs: options.timeoutMs ?? DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS,
})
return {
path,
reachable: Boolean(path),
paneControlReachable,
explicitDisable: false,
}
}
function classifyCmuxProbeFailureKind(result: ProbeCommandResult): CmuxProbeFailureKind {
const combinedOutput = `${result.stderr}\n${result.stdout}`
return isConnectionRefusedText(combinedOutput)
? "connection-refused"
: "exit-non-zero"
}
export async function probeCmuxReachability(options: ProbeOptions = {}): Promise<CmuxReachabilityProbe> {
const environment = options.environment ?? process.env
const socketPath = normalizeEnvValue(environment.CMUX_SOCKET_PATH)
const endpointType = classifyCmuxEndpoint(socketPath)
const workspaceId = normalizeEnvValue(environment.CMUX_WORKSPACE_ID)
const surfaceId = normalizeEnvValue(environment.CMUX_SURFACE_ID)
const hintStrength = resolveCmuxHintStrength(environment)
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
return {
path: null,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: false,
explicitDisable: true,
failureKind: "explicit-disable",
}
}
const path = await getCmuxPath()
if (!path) {
return {
path: null,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: false,
explicitDisable: false,
failureKind: "missing-binary",
}
}
if (!socketPath) {
return {
path,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: false,
explicitDisable: false,
failureKind: "missing-socket",
}
}
const probeResult = await runProbeCommand([path, "ping"], {
environment,
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS,
})
let effectiveProbeResult = probeResult
const firstFailureKind = classifyCmuxProbeFailureKind(probeResult)
if (
!probeResult.timedOut
&& probeResult.exitCode !== 0
&& endpointType === "relay"
&& firstFailureKind === "connection-refused"
) {
effectiveProbeResult = await runProbeCommand([path, "ping"], {
environment,
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS,
})
}
if (effectiveProbeResult.timedOut) {
return {
path,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: false,
explicitDisable: false,
failureKind: "timeout",
}
}
if (effectiveProbeResult.exitCode !== 0) {
const failureKind = classifyCmuxProbeFailureKind(effectiveProbeResult)
return {
path,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: false,
explicitDisable: false,
failureKind,
}
}
return {
path,
socketPath,
endpointType,
workspaceId,
surfaceId,
hintStrength,
reachable: true,
explicitDisable: false,
}
}
export async function probeCmuxNotificationCapability(
options: ProbeOptions & {
cmuxPath?: string | null
workspaceId?: string
surfaceId?: string
} = {},
): Promise<CmuxNotificationCapabilityProbe> {
const environment = options.environment ?? process.env
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
return {
capable: false,
explicitDisable: true,
failureKind: "explicit-disable",
}
}
const cmuxBinary = options.cmuxPath ?? await getCmuxPath()
if (!cmuxBinary) {
return {
capable: false,
explicitDisable: false,
failureKind: "missing-binary",
}
}
const probeResult = await runProbeCommand(buildCmuxNotifyProbeArgs({
cmuxBinary,
workspaceId: normalizeEnvValue(options.workspaceId),
surfaceId: normalizeEnvValue(options.surfaceId),
}), {
environment,
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS,
})
if (probeResult.timedOut) {
return {
capable: false,
explicitDisable: false,
failureKind: "timeout",
}
}
if (probeResult.exitCode !== 0) {
return {
capable: false,
explicitDisable: false,
failureKind: "exit-non-zero",
}
}
const helpOutput = `${probeResult.stdout}\n${probeResult.stderr}`
if (!supportsCmuxNotifyFlagModel(helpOutput)) {
return {
capable: false,
explicitDisable: false,
failureKind: "unsupported-contract",
}
}
return {
capable: true,
explicitDisable: false,
}
}
export async function probeCmuxRuntime(options: ProbeOptions = {}): Promise<CmuxRuntimeProbe> {
const reachability = await probeCmuxReachability(options)
const capability = await probeCmuxNotificationCapability({
...options,
cmuxPath: reachability.path,
workspaceId: reachability.workspaceId,
surfaceId: reachability.surfaceId,
})
return {
...reachability,
notifyCapable: capability.capable,
notifyFailureKind: capability.failureKind,
}
}
export function startBackgroundCheck(): void {
if (!tmuxPathInitPromise) {
tmuxPathInitPromise = getTmuxPath()
tmuxPathInitPromise.catch(() => {})
}
if (!cmuxPathInitPromise) {
cmuxPathInitPromise = getCmuxPath()
cmuxPathInitPromise.catch(() => {})
}
}
export function resetMultiplexerPathCacheForTesting(): void {
tmuxPath = null
tmuxPathInitialized = false
tmuxPathInitPromise = null
cmuxPath = null
cmuxPathInitialized = false
cmuxPathInitPromise = null
}

View File

@@ -1,7 +1,12 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./tmux-path-resolver"
import { getTmuxPath } from "./tmux-path-resolver"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../../shared/tmux"
/**
* Quote-aware command tokenizer with escape handling
@@ -48,34 +53,46 @@ export function tokenizeCommand(cmd: string): string[] {
return tokens
}
export const interactive_bash: ToolDefinition = tool({
description: INTERACTIVE_BASH_DESCRIPTION,
args: {
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
},
execute: async (args) => {
try {
const tmuxPath = getCachedTmuxPath() ?? "tmux"
const parts = tokenizeCommand(args.tmux_command)
if (parts.length === 0) {
return "Error: Empty tmux command"
}
const subcommand = parts[0].toLowerCase()
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
let sessionName = "omo-session"
if (sessionIdx !== -1) {
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
sessionName = parts[sessionIdx + 1]
} else if (parts[sessionIdx].startsWith("-t")) {
sessionName = parts[sessionIdx].slice(2)
}
export function createInteractiveBashTool(
runtime: ResolvedMultiplexer =
getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime(),
): ToolDefinition {
return tool({
description: INTERACTIVE_BASH_DESCRIPTION,
args: {
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
},
execute: async (args) => {
try {
if (runtime.paneBackend !== "tmux") {
return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${runtime.mode}' runtime.`
}
return `Error: '${parts[0]}' is blocked in interactive_bash.
const tmuxPath = await getTmuxPath()
if (!tmuxPath) {
return "Error: tmux executable is not reachable"
}
const parts = tokenizeCommand(args.tmux_command)
if (parts.length === 0) {
return "Error: Empty tmux command"
}
const subcommand = parts[0].toLowerCase()
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
let sessionName = "omo-session"
if (sessionIdx !== -1) {
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
sessionName = parts[sessionIdx + 1]
} else if (parts[sessionIdx].startsWith("-t")) {
sessionName = parts[sessionIdx].slice(2)
}
}
return `Error: '${parts[0]}' is blocked in interactive_bash.
**USE BASH TOOL INSTEAD:**
@@ -88,49 +105,52 @@ 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], {
stdout: "pipe",
stderr: "pipe",
})
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`)
try {
proc.kill()
// Fire-and-forget: wait for process exit in background to avoid zombies
void proc.exited.catch(() => {})
} catch {
// Ignore kill errors; we'll still reject with timeoutError below
}
reject(timeoutError)
}, DEFAULT_TIMEOUT_MS)
proc.exited
.then(() => clearTimeout(id))
.catch(() => clearTimeout(id))
})
// Read stdout and stderr in parallel to avoid race conditions
const [stdout, stderr, exitCode] = await Promise.race([
Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]),
timeoutPromise,
])
// Check exitCode properly - return error even if stderr is empty
if (exitCode !== 0) {
const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}`
return `Error: ${errorMsg}`
}
return stdout || "(no output)"
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
}
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
stdout: "pipe",
stderr: "pipe",
})
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`)
try {
proc.kill()
// Fire-and-forget: wait for process exit in background to avoid zombies
void proc.exited.catch(() => {})
} catch {
// Ignore kill errors; we'll still reject with timeoutError below
}
reject(timeoutError)
}, DEFAULT_TIMEOUT_MS)
proc.exited
.then(() => clearTimeout(id))
.catch(() => clearTimeout(id))
})
// Read stdout and stderr in parallel to avoid race conditions
const [stdout, stderr, exitCode] = await Promise.race([
Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]),
timeoutPromise,
])
// Check exitCode properly - return error even if stderr is empty
if (exitCode !== 0) {
const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}`
return `Error: ${errorMsg}`
}
return stdout || "(no output)"
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const interactive_bash: ToolDefinition = createInteractiveBashTool()