Compare commits

...

6 Commits

Author SHA1 Message Date
Kenny
2b02e2c1a0 fix: follow up cmux runtime and scheduler edge cases 2026-03-29 20:07:23 +08:00
Kenny
6ffadaaa51 fix: harden cmux fallback retries and tmux runtime assertions 2026-03-29 18:57:49 +08:00
Kenny
7088120045 fix: follow up cmux timeout and interactive_bash runtime regressions 2026-03-29 16:55:31 +08:00
Kenny
f7ac464194 fix: follow up cmux probing and tmux session resolution 2026-03-29 16:07:41 +08:00
Kenny
73f5ae968f fix: tighten cmux runtime probing and fallback semantics 2026-03-29 15:35:59 +08:00
Kenny
64a87a78d6 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.
2026-03-29 13:56:31 +08:00
34 changed files with 2962 additions and 182 deletions

View File

@@ -23,6 +23,7 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr
- [Commands](#commands) - [Commands](#commands)
- [Browser Automation](#browser-automation) - [Browser Automation](#browser-automation)
- [Tmux Integration](#tmux-integration) - [Tmux Integration](#tmux-integration)
- [Cmux Integration](#cmux-integration)
- [Git Master](#git-master) - [Git Master](#git-master)
- [Comment Checker](#comment-checker) - [Comment Checker](#comment-checker)
- [Notification](#notification) - [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 | | `main_pane_min_width` | `120` | Min main pane columns |
| `agent_pane_min_width` | `40` | Min agent 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 (if capable), else desktop |
| `tmux-only` | Live tmux pane control, no live cmux | tmux | 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
**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) and weak hints (e.g., `TERM_PROGRAM=ghostty`) are recorded in the runtime metadata but do not affect mode resolution or probe results.
#### 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, notification-capable 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 ### Git Master
Configure git commit behavior: Configure git commit behavior:
@@ -971,8 +1027,13 @@ When enabled, two companion hooks are active: `hashline-read-enhancer` (annotate
### Environment Variables ### Environment Variables
| Variable | Description | | Variable | Description |
| --------------------- | ----------------------------------------------------------------- | | ------------------------------ | --------------------------------------------------------------------------- |
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) | | `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 ### 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 { BackgroundManager } from "./features/background-agent"
import type { PluginContext } from "./plugin/types" import type { PluginContext } from "./plugin/types"
import type { ModelCacheState } from "./plugin-state" import type { ModelCacheState } from "./plugin-state"
import type { ResolvedMultiplexer } from "./shared/tmux"
import { createCoreHooks } from "./plugin/hooks/create-core-hooks" import { createCoreHooks } from "./plugin/hooks/create-core-hooks"
import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks"
@@ -34,6 +35,7 @@ export function createHooks(args: {
safeHookEnabled: boolean safeHookEnabled: boolean
mergedSkills: LoadedSkill[] mergedSkills: LoadedSkill[]
availableSkills: AvailableSkill[] availableSkills: AvailableSkill[]
resolvedMultiplexer: ResolvedMultiplexer
}) { }) {
const { const {
ctx, ctx,
@@ -44,6 +46,7 @@ export function createHooks(args: {
safeHookEnabled, safeHookEnabled,
mergedSkills, mergedSkills,
availableSkills, availableSkills,
resolvedMultiplexer,
} = args } = args
const core = createCoreHooks({ const core = createCoreHooks({
@@ -52,6 +55,7 @@ export function createHooks(args: {
modelCacheState, modelCacheState,
isHookEnabled, isHookEnabled,
safeHookEnabled, safeHookEnabled,
resolvedMultiplexer,
}) })
const continuation = createContinuationHooks({ const continuation = createContinuationHooks({

View File

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

View File

@@ -22,7 +22,7 @@ export type CreateToolsResult = {
export async function createTools(args: { export async function createTools(args: {
ctx: PluginContext ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager"> managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
}): Promise<CreateToolsResult> { }): Promise<CreateToolsResult> {
const { ctx, pluginConfig, managers } = args 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 { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" 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 { import {
shouldRetryError, shouldRetryError,
hasMoreFallbacks, hasMoreFallbacks,
@@ -141,6 +145,7 @@ export class BackgroundManager {
private shutdownTriggered = false private shutdownTriggered = false
private config?: BackgroundTaskConfig private config?: BackgroundTaskConfig
private tmuxEnabled: boolean private tmuxEnabled: boolean
private resolvedMultiplexer: ResolvedMultiplexer
private onSubagentSessionCreated?: OnSubagentSessionCreated private onSubagentSessionCreated?: OnSubagentSessionCreated
private onShutdown?: () => void | Promise<void> private onShutdown?: () => void | Promise<void>
@@ -161,6 +166,7 @@ export class BackgroundManager {
config?: BackgroundTaskConfig, config?: BackgroundTaskConfig,
options?: { options?: {
tmuxConfig?: TmuxConfig tmuxConfig?: TmuxConfig
resolvedMultiplexer?: ResolvedMultiplexer
onSubagentSessionCreated?: OnSubagentSessionCreated onSubagentSessionCreated?: OnSubagentSessionCreated
onShutdown?: () => void | Promise<void> onShutdown?: () => void | Promise<void>
enableParentSessionNotifications?: boolean enableParentSessionNotifications?: boolean
@@ -175,6 +181,10 @@ export class BackgroundManager {
this.concurrencyManager = new ConcurrencyManager(config) this.concurrencyManager = new ConcurrencyManager(config)
this.config = config this.config = config
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
this.resolvedMultiplexer =
options?.resolvedMultiplexer
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.onSubagentSessionCreated = options?.onSubagentSessionCreated
this.onShutdown = options?.onShutdown this.onShutdown = options?.onShutdown
this.rootDescendantCounts = new Map() this.rootDescendantCounts = new Map()
@@ -455,12 +465,17 @@ export class BackgroundManager {
log("[background-agent] tmux callback check", { log("[background-agent] tmux callback check", {
hasCallback: !!this.onSubagentSessionCreated, hasCallback: !!this.onSubagentSessionCreated,
tmuxEnabled: this.tmuxEnabled, tmuxEnabled: this.tmuxEnabled,
isInsideTmux: isInsideTmux(), paneBackend: this.resolvedMultiplexer.paneBackend,
multiplexerMode: this.resolvedMultiplexer.mode,
sessionID, sessionID,
parentID: input.parentSessionID, 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 }) log("[background-agent] Invoking tmux callback NOW", { sessionID })
await this.onSubagentSessionCreated({ await this.onSubagentSessionCreated({
sessionID, sessionID,

View File

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

View File

@@ -3,6 +3,7 @@ import type { TmuxConfig } from '../../config/schema'
import type { WindowState, PaneAction } from './types' import type { WindowState, PaneAction } from './types'
import type { ActionResult, ExecuteContext } from './action-executor' import type { ActionResult, ExecuteContext } from './action-executor'
import type { TmuxUtilDeps } from './manager' import type { TmuxUtilDeps } from './manager'
import type { ResolvedMultiplexer } from '../../shared/tmux'
import * as sharedModule from '../../shared' import * as sharedModule from '../../shared'
type ExecuteActionsResult = { type ExecuteActionsResult = {
@@ -40,6 +41,8 @@ const mockTmuxDeps: TmuxUtilDeps = {
getCurrentPaneId: mockGetCurrentPaneId, getCurrentPaneId: mockGetCurrentPaneId,
} }
let mockedResolvedMultiplexerRuntime: ResolvedMultiplexer | null = null
mock.module('./pane-state-querier', () => ({ mock.module('./pane-state-querier', () => ({
queryWindowState: mockQueryWindowState, queryWindowState: mockQueryWindowState,
paneExists: mockPaneExists, paneExists: mockPaneExists,
@@ -61,8 +64,13 @@ mock.module('./action-executor', () => ({
mock.module('../../shared/tmux', () => { mock.module('../../shared/tmux', () => {
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils') const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
const {
createDisabledMultiplexerRuntime,
} = require('../../shared/tmux/tmux-utils/multiplexer-runtime')
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants') const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
return { return {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime: () => mockedResolvedMultiplexerRuntime,
isInsideTmux, isInsideTmux,
getCurrentPaneId, getCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_BACKGROUND_MS,
@@ -135,6 +143,7 @@ describe('TmuxSessionManager', () => {
mockExecuteAction.mockClear() mockExecuteAction.mockClear()
mockIsInsideTmux.mockClear() mockIsInsideTmux.mockClear()
mockGetCurrentPaneId.mockClear() mockGetCurrentPaneId.mockClear()
mockedResolvedMultiplexerRuntime = null
trackedSessions.clear() trackedSessions.clear()
mockQueryWindowState.mockImplementation(async () => createWindowState()) mockQueryWindowState.mockImplementation(async () => createWindowState())
@@ -227,6 +236,54 @@ describe('TmuxSessionManager', () => {
expect(manager).toBeDefined() expect(manager).toBeDefined()
}) })
test('legacy deps constructor ignores global multiplexer cache', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockedResolvedMultiplexerRuntime = {
platform: 'darwin',
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,
},
}
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
// when
await manager.onSessionCreated(
createSessionCreatedEvent('ses_cache_ignored', 'ses_parent', 'Cache Ignored')
)
// then
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
})
test('falls back to default port when serverUrl has port 0', async () => { test('falls back to default port when serverUrl has port 0', async () => {
// given // given
mockIsInsideTmux.mockReturnValue(true) mockIsInsideTmux.mockReturnValue(true)

View File

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

View File

@@ -0,0 +1,189 @@
import { describe, expect, test } from "bun:test"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
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 when output reports connection-refused", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 0,
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)
})
test("returns promptly on timeout when cmux process ignores TERM", async () => {
if (process.platform === "win32") {
return
}
const tempDirectory = mkdtempSync(join(tmpdir(), "cmux-notify-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)
const runtime = createResolvedMultiplexer()
runtime.cmux.path = fakeCmuxPath
try {
const adapter = createCmuxNotificationAdapter({
runtime,
environment: {
PATH: tempDirectory,
},
timeoutMs: 40,
})
const startedAt = Date.now()
const delivered = await adapter.send("OpenCode", "Task complete")
const elapsedMs = Date.now() - startedAt
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
expect(elapsedMs).toBeLessThan(500)
} finally {
rmSync(tempDirectory, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,202 @@
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)
}
if (timedOut) {
// Do not await stdout/stderr after timeout: a process that ignores TERM
// may keep pipes open and block fallback completion.
void proc.exited.catch(() => {})
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(() => "")
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
}
const combinedOutput = `${result.stderr}\n${result.stdout}`
if (isConnectionRefusedText(combinedOutput)) {
return true
}
if (result.exitCode === 0) {
return false
}
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

@@ -0,0 +1,88 @@
import { afterEach, describe, expect, jest, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
function createDeferred<T>() {
let resolvePromise: (value: T | PromiseLike<T>) => void = () => {}
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve
})
return {
promise,
resolve: resolvePromise,
}
}
describe("session-notification-scheduler", () => {
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
test("does not resend when notification version entry is evicted during delivery", async () => {
jest.useFakeTimers()
const firstSendGate = createDeferred<void>()
let firstSendStarted = false
const sendCalls: string[] = []
const scheduler = createIdleNotificationScheduler({
ctx: {} as PluginInput,
platform: "darwin",
config: {
playSound: false,
soundPath: "",
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
maxTrackedSessions: 1,
activityGracePeriodMs: 0,
},
hasIncompleteTodos: async () => false,
send: async (_ctx, _platform, sessionID) => {
sendCalls.push(sessionID)
if (sessionID !== "session-a") {
return true
}
firstSendStarted = true
await firstSendGate.promise
return true
},
playSound: async () => {},
})
scheduler.scheduleIdleNotification("session-a")
jest.advanceTimersByTime(10)
await flushMicrotasks()
expect(sendCalls).toEqual(["session-a"])
scheduler.scheduleIdleNotification("session-b")
jest.advanceTimersByTime(10)
await flushMicrotasks()
expect(sendCalls).toEqual(["session-a", "session-b"])
if (!firstSendStarted) {
throw new Error("Expected the first send call to be in-flight")
}
firstSendGate.resolve()
await flushMicrotasks()
scheduler.scheduleIdleNotification("session-a")
jest.advanceTimersByTime(10)
await flushMicrotasks()
const sessionASendCount = sendCalls.filter(id => id === "session-a").length
expect(sessionASendCount).toBe(1)
})
})

View File

@@ -16,7 +16,7 @@ export function createIdleNotificationScheduler(options: {
platform: Platform platform: Platform
config: SessionNotificationConfig config: SessionNotificationConfig
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean> hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void> send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<boolean>
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void> playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
}) { }) {
const notifiedSessions = new Set<string>() const notifiedSessions = new Set<string>()
@@ -48,12 +48,6 @@ export function createIdleNotificationScheduler(options: {
notificationVersions.delete(id) notificationVersions.delete(id)
}) })
} }
if (executingNotifications.size > maxSessions) {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach((id) => {
executingNotifications.delete(id)
})
}
if (scheduledAt.size > maxSessions) { if (scheduledAt.size > maxSessions) {
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions) const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
sessionsToRemove.forEach((id) => { sessionsToRemove.forEach((id) => {
@@ -89,6 +83,15 @@ export function createIdleNotificationScheduler(options: {
} }
} }
function hasStaleNotificationVersion(sessionID: string, version: number): boolean {
const latestVersion = notificationVersions.get(sessionID)
if (latestVersion === undefined) {
return !executingNotifications.has(sessionID)
}
return latestVersion !== version
}
async function executeNotification(sessionID: string, version: number): Promise<void> { async function executeNotification(sessionID: string, version: number): Promise<void> {
if (executingNotifications.has(sessionID)) { if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID) pendingTimers.delete(sessionID)
@@ -96,7 +99,7 @@ export function createIdleNotificationScheduler(options: {
return return
} }
if (notificationVersions.get(sessionID) !== version) { if (hasStaleNotificationVersion(sessionID, version)) {
pendingTimers.delete(sessionID) pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID) scheduledAt.delete(sessionID)
return return
@@ -119,13 +122,27 @@ export function createIdleNotificationScheduler(options: {
try { try {
if (options.config.skipIfIncompleteTodos) { if (options.config.skipIfIncompleteTodos) {
const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID) const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID)
if (notificationVersions.get(sessionID) !== version) { if (hasStaleNotificationVersion(sessionID, version)) {
return return
} }
if (hasPendingWork) return if (hasPendingWork) return
} }
if (notificationVersions.get(sessionID) !== version) { if (hasStaleNotificationVersion(sessionID, version)) {
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
return
}
const delivered = await options.send(options.ctx, options.platform, sessionID)
if (!delivered) {
return
}
if (hasStaleNotificationVersion(sessionID, version)) {
return return
} }
@@ -136,8 +153,6 @@ export function createIdleNotificationScheduler(options: {
notifiedSessions.add(sessionID) notifiedSessions.add(sessionID)
await options.send(options.ctx, options.platform, sessionID)
if (options.config.playSound && options.config.soundPath) { if (options.config.playSound && options.config.soundPath) {
await options.playSound(options.ctx, options.platform, options.config.soundPath) await options.playSound(options.ctx, options.platform, options.config.soundPath)
} }

View File

@@ -3,6 +3,8 @@ import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils" import * as utils from "./session-notification-utils"
import * as sender from "./session-notification-sender" 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 originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout const originalClearTimeout = globalThis.clearTimeout
@@ -33,6 +35,42 @@ describe("session-notification", () => {
} as any } 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(() => { beforeEach(() => {
jest.useRealTimers() jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout globalThis.setTimeout = originalSetTimeout
@@ -387,6 +425,233 @@ describe("session-notification", () => {
expect(notificationCalls).toHaveLength(1) 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("retries idle notification when cmux fails on unsupported platform", async () => {
const sessionID = "cmux-unsupported-retry"
let cmuxSendCalls = 0
let cmuxNotifyCommandCalls = 0
let cmuxAvailable = true
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
try {
const cmuxAdapter = createCmuxAdapter({
canSendViaCmux: () => cmuxAvailable,
hasDowngraded: () => !cmuxAvailable,
send: async () => {
cmuxSendCalls += 1
if (!cmuxAvailable) {
return false
}
cmuxNotifyCommandCalls += 1
cmuxAvailable = false
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))
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(cmuxSendCalls).toBe(2)
expect(cmuxNotifyCommandCalls).toBe(1)
expect(notificationCalls).toHaveLength(0)
} finally {
detectPlatformSpy.mockRestore()
}
})
test("skips unsupported idle scheduling when cmux was never available", async () => {
const sessionID = "cmux-unsupported-unavailable"
let cmuxSendCalls = 0
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
try {
const cmuxAdapter = createCmuxAdapter({
canSendViaCmux: () => false,
hasDowngraded: () => false,
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(0)
expect(notificationCalls).toHaveLength(0)
} finally {
detectPlatformSpy.mockRestore()
}
})
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() { function createSenderMockCtx() {
const notifyCalls: string[] = [] const notifyCalls: string[] = []
const mockCtx = { const mockCtx = {

View File

@@ -10,6 +10,15 @@ import {
import * as sessionNotificationSender from "./session-notification-sender" import * as sessionNotificationSender from "./session-notification-sender"
import { hasIncompleteTodos } from "./session-todo-status" import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler" import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../shared/tmux"
import {
createCmuxNotificationAdapter,
type CmuxNotificationAdapter,
} from "./cmux-notification-adapter"
interface SessionNotificationConfig { interface SessionNotificationConfig {
title?: string title?: string
@@ -30,10 +39,24 @@ interface SessionNotificationConfig {
} }
export function createSessionNotification( export function createSessionNotification(
ctx: PluginInput, ctx: PluginInput,
config: SessionNotificationConfig = {} config: SessionNotificationConfig = {},
options: {
resolvedMultiplexer?: ResolvedMultiplexer
cmuxNotificationAdapter?: CmuxNotificationAdapter
} = {},
) { ) {
const currentPlatform: Platform = sessionNotificationSender.detectPlatform() const currentPlatform: Platform = sessionNotificationSender.detectPlatform()
const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform) const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform)
const resolvedMultiplexer =
options.resolvedMultiplexer
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
const cmuxNotificationAdapter =
options.cmuxNotificationAdapter
?? createCmuxNotificationAdapter({
runtime: resolvedMultiplexer,
environment: process.env,
})
startBackgroundCheck(currentPlatform) startBackgroundCheck(currentPlatform)
@@ -61,13 +84,25 @@ export function createSessionNotification(
typeof hookCtx.client.session.get !== "function" typeof hookCtx.client.session.get !== "function"
&& typeof hookCtx.client.session.messages !== "function" && typeof hookCtx.client.session.messages !== "function"
) { ) {
const deliveredViaCmux = await cmuxNotificationAdapter.send(
mergedConfig.title,
mergedConfig.message,
)
if (deliveredViaCmux) {
return true
}
if (platform === "unsupported") {
return false
}
await sessionNotificationSender.sendSessionNotification( await sessionNotificationSender.sendSessionNotification(
hookCtx, hookCtx,
platform, platform,
mergedConfig.title, mergedConfig.title,
mergedConfig.message, mergedConfig.message,
) )
return return true
} }
const content = await buildReadyNotificationContent(hookCtx, { const content = await buildReadyNotificationContent(hookCtx, {
@@ -76,7 +111,17 @@ export function createSessionNotification(
baseMessage: mergedConfig.message, baseMessage: mergedConfig.message,
}) })
const deliveredViaCmux = await cmuxNotificationAdapter.send(content.title, content.message)
if (deliveredViaCmux) {
return true
}
if (platform === "unsupported") {
return false
}
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message) await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
return true
}, },
playSound: sessionNotificationSender.playSessionNotificationSound, playSound: sessionNotificationSender.playSessionNotificationSound,
}) })
@@ -134,7 +179,13 @@ export function createSessionNotification(
} }
return async ({ event }: { event: { type: string; properties?: unknown } }) => { return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return const cannotDeliverOnUnsupportedPlatform =
currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()
const shouldFastExitUnsupportedEvent =
cannotDeliverOnUnsupportedPlatform
&& (event.type !== "session.idle" || !cmuxNotificationAdapter.hasDowngraded())
if (shouldFastExitUnsupportedEvent) return
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
@@ -172,12 +223,18 @@ export function createSessionNotification(
if (!shouldNotifyForSession(sessionID)) return if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID) scheduler.markSessionActivity(sessionID)
const deliveredViaCmux = await cmuxNotificationAdapter.send(
mergedConfig.title,
mergedConfig.permissionMessage,
)
if (!deliveredViaCmux && currentPlatform !== "unsupported") {
await sessionNotificationSender.sendSessionNotification( await sessionNotificationSender.sendSessionNotification(
ctx, ctx,
currentPlatform, currentPlatform,
mergedConfig.title, mergedConfig.title,
mergedConfig.permissionMessage, mergedConfig.permissionMessage,
) )
}
if (mergedConfig.playSound && mergedConfig.soundPath) { if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath) await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
} }
@@ -199,7 +256,10 @@ export function createSessionNotification(
? mergedConfig.permissionMessage ? mergedConfig.permissionMessage
: mergedConfig.questionMessage : mergedConfig.questionMessage
const deliveredViaCmux = await cmuxNotificationAdapter.send(mergedConfig.title, message)
if (!deliveredViaCmux && currentPlatform !== "unsupported") {
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message) await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
}
if (mergedConfig.playSound && mergedConfig.soundPath) { if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, 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 { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state" import { createModelCacheState } from "./plugin-state"
import { createFirstMessageVariantGate } from "./shared/first-message-variant" 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 { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startTmuxCheck } from "./tools"
let activePluginDispose: PluginDispose | null = null let activePluginDispose: PluginDispose | null = null
@@ -33,7 +38,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
} }
injectServerAuthIntoClient(ctx.client) injectServerAuthIntoClient(ctx.client)
startTmuxCheck()
await activePluginDispose?.() await activePluginDispose?.()
const pluginConfig = loadPluginConfig(ctx.directory, ctx) const pluginConfig = loadPluginConfig(ctx.directory, ctx)
@@ -54,10 +58,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const modelCacheState = createModelCacheState() const modelCacheState = createModelCacheState()
const resolvedMultiplexer = await resolveMultiplexerRuntime({
environment: process.env,
tmuxEnabled: tmuxConfig.enabled,
})
setResolvedMultiplexerRuntime(resolvedMultiplexer)
const managers = createManagers({ const managers = createManagers({
ctx, ctx,
pluginConfig, pluginConfig,
tmuxConfig, tmuxConfig,
resolvedMultiplexer,
modelCacheState, modelCacheState,
backgroundNotificationHookEnabled: isHookEnabled("background-notification"), backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
}) })
@@ -77,6 +88,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
safeHookEnabled, safeHookEnabled,
mergedSkills: toolsResult.mergedSkills, mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills, availableSkills: toolsResult.availableSkills,
resolvedMultiplexer,
}) })
const dispose = createPluginDispose({ const dispose = createPluginDispose({

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,19 @@
import { describe, expect, test } from "bun:test" import { beforeEach, describe, expect, spyOn, test } from "bun:test"
import { analyzePaneContent } from "../tmux" import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver"
import {
createDisabledMultiplexerRuntime,
resetResolvedMultiplexerRuntimeForTesting,
setResolvedMultiplexerRuntime,
} from "../../shared/tmux"
import { analyzePaneContent, getCurrentTmuxSession } from "../tmux"
import * as tmuxPathResolver from "../../tools/interactive-bash/tmux-path-resolver"
describe("openclaw tmux helpers", () => { describe("openclaw tmux helpers", () => {
beforeEach(() => {
resetMultiplexerPathCacheForTesting()
resetResolvedMultiplexerRuntimeForTesting()
})
test("analyzePaneContent recognizes the opencode welcome prompt", () => { test("analyzePaneContent recognizes the opencode welcome prompt", () => {
const content = "opencode\nAsk anything...\nRun /help" const content = "opencode\nAsk anything...\nRun /help"
expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1) expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1)
@@ -10,4 +22,35 @@ describe("openclaw tmux helpers", () => {
test("analyzePaneContent returns zero confidence for empty content", () => { test("analyzePaneContent returns zero confidence for empty content", () => {
expect(analyzePaneContent(null).confidence).toBe(0) 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 getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue("/usr/bin/tmux")
try {
process.env.TMUX = "/tmp/tmux-501/default,1,0"
process.env.TMUX_PANE = "%42"
setResolvedMultiplexerRuntime(createDisabledMultiplexerRuntime())
const sessionName = await getCurrentTmuxSession()
expect(sessionName).toBeNull()
expect(getTmuxPathSpy).not.toHaveBeenCalled()
} 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
}
getTmuxPathSpy.mockRestore()
}
})
}) })

View File

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

View File

@@ -1,16 +1,33 @@
import { spawn } from "bun" 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 { export async function getCurrentTmuxSession(): Promise<string | null> {
const env = process.env.TMUX const resolvedMultiplexer = getResolvedMultiplexerRuntime()
if (!env) return null if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
const match = env.match(/(\d+)$/) return null
return match ? `session-${match[1]}` : null // Wait, TMUX env is /tmp/tmux-501/default,1234,0 }
// Reference tmux.js gets session name via `tmux display-message -p '#S'`
if (!isInsideTmux(resolvedMultiplexer ?? undefined)) {
return null
}
const paneId = getCurrentPaneId(resolvedMultiplexer ?? undefined)
if (!paneId) return null
return getTmuxSessionName()
} }
export async function getTmuxSessionName(): Promise<string | null> { export async function getTmuxSessionName(): Promise<string | null> {
try { 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", stdout: "pipe",
stderr: "ignore", stderr: "ignore",
}) })
@@ -27,8 +44,11 @@ export async function getTmuxSessionName(): Promise<string | null> {
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> { export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
try { try {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return null
const proc = spawn( const proc = spawn(
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], [tmuxPath, "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
{ {
stdout: "pipe", stdout: "pipe",
stderr: "ignore", stderr: "ignore",
@@ -46,7 +66,10 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> { export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
try { 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", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })
@@ -55,7 +78,7 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
if (!confirm) return 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", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })
@@ -67,8 +90,16 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
} }
export async function isTmuxAvailable(): Promise<boolean> { export async function isTmuxAvailable(): Promise<boolean> {
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
return false
}
try { try {
const proc = spawn(["tmux", "-V"], { const tmuxPath = await getTmuxPath()
if (!tmuxPath) return false
const proc = spawn([tmuxPath, "-V"], {
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })

View File

@@ -341,6 +341,7 @@ export function createEventHandler(args: {
firstMessageVariantGate.markSessionCreated(sessionInfo); firstMessageVariantGate.markSessionCreated(sessionInfo);
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
await managers.tmuxSessionManager.onSessionCreated( await managers.tmuxSessionManager.onSessionCreated(
event as { event as {
type: string; type: string;
@@ -350,6 +351,7 @@ export function createEventHandler(args: {
}, },
); );
} }
}
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionInfo = props?.info as { id?: string } | undefined;
@@ -376,11 +378,13 @@ export function createEventHandler(args: {
deleteSessionTools(sessionInfo.id); deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id); await managers.skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients(); await lspManager.cleanupTempDirectoryClients();
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
await managers.tmuxSessionManager.onSessionDeleted({ await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id, sessionID: sessionInfo.id,
}); });
} }
} }
}
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined; const info = props?.info as Record<string, unknown> | undefined;

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ import {
createSessionManagerTools, createSessionManagerTools,
createDelegateTask, createDelegateTask,
discoverCommandsSync, discoverCommandsSync,
interactive_bash, createInteractiveBashTool,
createTaskCreateTool, createTaskCreateTool,
createTaskGetTool, createTaskGetTool,
createTaskList, createTaskList,
@@ -100,7 +100,7 @@ function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
export function createToolRegistry(args: { export function createToolRegistry(args: {
ctx: PluginContext ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager"> managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
skillContext: SkillContext skillContext: SkillContext
availableCategories: AvailableCategory[] availableCategories: AvailableCategory[]
}): ToolRegistryResult { }): ToolRegistryResult {
@@ -134,6 +134,10 @@ export function createToolRegistry(args: {
availableSkills: skillContext.availableSkills, availableSkills: skillContext.availableSkills,
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
onSyncSessionCreated: async (event) => { onSyncSessionCreated: async (event) => {
if (managers.resolvedMultiplexer.paneBackend !== "tmux") {
return
}
log("[index] onSyncSessionCreated callback", { log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID, sessionID: event.sessionID,
parentID: event.parentID, parentID: event.parentID,
@@ -202,7 +206,7 @@ export function createToolRegistry(args: {
task: delegateTask, task: delegateTask,
skill_mcp: skillMcpTool, skill_mcp: skillMcpTool,
skill: skillTool, skill: skillTool,
interactive_bash, interactive_bash: createInteractiveBashTool(managers.resolvedMultiplexer),
...taskToolsRecord, ...taskToolsRecord,
...hashlineToolsRecord, ...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 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 { isServerRunning, resetServerCheck, markServerRunningInProcess } from "./tmux-utils/server-health"
export { getPaneDimensions } from "./tmux-utils/pane-dimensions" 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" export type SplitDirection = "-h" | "-v"
function resolveRuntime(runtime: ResolvedMultiplexer | undefined): ResolvedMultiplexer | null {
return runtime ?? getResolvedMultiplexerRuntime()
}
export function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean { export function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
return Boolean(environment.TMUX) return Boolean(environment.TMUX)
} }
export function isInsideTmux(): boolean { export function isInsideTmux(runtime?: ResolvedMultiplexer): boolean {
const resolvedRuntime = resolveRuntime(runtime)
if (resolvedRuntime) {
return resolvedRuntime.paneBackend === "tmux"
}
return isInsideTmuxEnvironment(process.env) return isInsideTmuxEnvironment(process.env)
} }
export function getCurrentPaneId(): string | undefined { 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 return process.env.TMUX_PANE
} }
export function isTmuxPaneControlAvailable(runtime?: ResolvedMultiplexer): boolean {
return isInsideTmux(runtime)
}

View File

@@ -0,0 +1,297 @@
import { describe, expect, spyOn, test } from "bun:test"
import {
createDisabledMultiplexerRuntime,
resolveMultiplexerFromProbes,
resolveMultiplexerRuntime,
type ResolvedMultiplexer,
} from "./multiplexer-runtime"
import {
probeCmuxReachability,
resetMultiplexerPathCacheForTesting,
type CmuxRuntimeProbe,
type 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", async () => {
const derivedProbe = await probeCmuxReachability({
environment: {
CMUX_SOCKET_PATH: "127.0.0.1:7777",
OH_MY_OPENCODE_DISABLE_CMUX: "1",
},
})
const runtime = resolveRuntime({
environment: {
TMUX: undefined,
TMUX_PANE: undefined,
CMUX_SOCKET_PATH: "127.0.0.1:7777",
},
cmuxProbe: {
endpointType: derivedProbe.endpointType,
socketPath: derivedProbe.socketPath,
},
})
expect(derivedProbe.endpointType).toBe("relay")
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", async () => {
const derivedProbe = await probeCmuxReachability({
environment: {
TERM_PROGRAM: "ghostty",
CMUX_SOCKET_PATH: undefined,
OH_MY_OPENCODE_DISABLE_CMUX: "1",
},
})
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: derivedProbe.socketPath,
endpointType: derivedProbe.endpointType,
hintStrength: derivedProbe.hintStrength,
notifyCapable: false,
failureKind: "missing-socket",
},
})
expect(derivedProbe.hintStrength).toBe("weak")
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)
})
test("skips tmux and cmux path probing when both backends are disabled", async () => {
resetMultiplexerPathCacheForTesting()
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await resolveMultiplexerRuntime({
environment: {},
tmuxEnabled: false,
cmuxEnabled: false,
})
expect(whichSpy).toHaveBeenCalledTimes(0)
} finally {
whichSpy.mockRestore()
}
})
test("only probes cmux path when tmux backend is disabled", async () => {
resetMultiplexerPathCacheForTesting()
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await resolveMultiplexerRuntime({
environment: {
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
},
tmuxEnabled: false,
cmuxEnabled: true,
})
expect(whichSpy.mock.calls.length).toBeGreaterThan(0)
expect(whichSpy.mock.calls.every((call) => call[0] === "cmux")).toBe(true)
} finally {
whichSpy.mockRestore()
}
})
})

View File

@@ -0,0 +1,236 @@
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"
}
function createDisabledTmuxProbe(): TmuxRuntimeProbe {
return {
path: null,
reachable: false,
paneControlReachable: false,
explicitDisable: false,
}
}
function createDisabledCmuxProbe(): CmuxRuntimeProbe {
return {
path: null,
socketPath: undefined,
endpointType: "missing",
workspaceId: undefined,
surfaceId: undefined,
hintStrength: "none",
reachable: false,
explicitDisable: false,
notifyCapable: false,
}
}
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 tmuxProbePromise = options.tmuxProbe
? Promise.resolve(options.tmuxProbe)
: tmuxEnabled
? probeTmuxRuntime({ environment })
: Promise.resolve(createDisabledTmuxProbe())
const cmuxProbePromise = options.cmuxProbe
? Promise.resolve(options.cmuxProbe)
: cmuxEnabled
? probeCmuxRuntime({ environment })
: Promise.resolve(createDisabledCmuxProbe())
const [tmuxProbe, cmuxProbe] = await Promise.all([tmuxProbePromise, cmuxProbePromise])
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 { 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" export { createSkillMcpTool } from "./skill-mcp"
import { import {

View File

@@ -11,7 +11,7 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [
"pipep", "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 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" import { startBackgroundCheck } from "./tmux-path-resolver"
export { interactive_bash, startBackgroundCheck } export { interactive_bash, createInteractiveBashTool, startBackgroundCheck }

View File

@@ -0,0 +1,166 @@
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,
supportsCmuxNotifyFlagModel,
} from "./tmux-path-resolver"
describe("tmux-path-resolver probe environment", () => {
beforeEach(() => {
resetMultiplexerPathCacheForTesting()
})
test("probeTmuxRuntime resolves tmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeTmuxRuntime({
environment: {
PATH: "/tmp/custom-tmux-bin",
TMUX: "/tmp/tmux-501/default,1,0",
TMUX_PANE: "%1",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"tmux",
{ PATH: "/tmp/custom-tmux-bin" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeCmuxReachability resolves cmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeCmuxReachability({
environment: {
PATH: "/tmp/custom-cmux-bin",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"cmux",
{ PATH: "/tmp/custom-cmux-bin" },
])
} finally {
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 () => {
if (process.platform === "win32") {
return
}
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", () => {
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,641 @@
import { spawn } from "bun" 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 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> { let cmuxPath: string | null = null
const isWindows = process.platform === "win32" let cmuxPathInitialized = false
const cmd = isWindows ? "where" : "which" 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 { try {
const proc = spawn([cmd, "tmux"], { const proc = spawn(args, {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: toProbeEnvironment(options.environment),
}) })
const exitCode = await proc.exited const timeoutMs = options.timeoutMs ?? DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS
if (exitCode !== 0) { let timeoutHandle: ReturnType<typeof setTimeout> | undefined
return null
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() if (timedOut) {
const path = stdout.trim().split("\n")[0] return {
exitCode: null,
if (!path) { stdout: "",
return null stderr: "",
timedOut: true,
}
} }
const verifyProc = spawn([path, "-V"], { const exitCode = await proc.exited.catch(() => null)
stdout: "pipe", const stdout = await new Response(proc.stdout).text().catch(() => "")
stderr: "pipe", const stderr = await new Response(proc.stderr).text().catch(() => "")
})
const verifyExitCode = await verifyProc.exited return {
if (verifyExitCode !== 0) { exitCode,
return null stdout,
stderr,
timedOut,
} }
} catch {
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: false,
}
}
}
return path function findCommandPath(
commandName: string,
environment?: Record<string, string | undefined>,
): string | null {
try {
const probeEnvironment = toProbeEnvironment(environment)
const hasExplicitPathOverride =
environment !== undefined
&& Object.prototype.hasOwnProperty.call(environment, "PATH")
const whichOptions = hasExplicitPathOverride
? { PATH: probeEnvironment.PATH ?? "" }
: probeEnvironment.PATH !== undefined
? { PATH: probeEnvironment.PATH }
: undefined
const discovered = Bun.which(commandName, whichOptions)
return discovered ?? null
} catch { } catch {
return null return null
} }
} }
async function resolveExecutablePath(
commandName: string,
verifyArgs: string[],
environment?: Record<string, string | undefined>,
): Promise<string | null> {
const discovered = findCommandPath(commandName, environment)
if (!discovered) {
return null
}
const verification = await runProbeCommand([discovered, ...verifyArgs], {
environment,
})
if (verification.timedOut || verification.exitCode !== 0) {
return null
}
return discovered
}
async function findTmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("tmux", ["-V"], environment)
}
async function findCmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("cmux", ["--help"], environment)
}
export async function getTmuxPath(): Promise<string | null> { export async function getTmuxPath(): Promise<string | null> {
if (tmuxPath !== null) { if (tmuxPathInitialized) {
return tmuxPath return tmuxPath
} }
if (initPromise) { if (tmuxPathInitPromise) {
return initPromise return tmuxPathInitPromise
} }
initPromise = (async () => { tmuxPathInitPromise = (async () => {
const path = await findTmuxPath() const path = await findTmuxPath()
tmuxPath = path tmuxPath = path
tmuxPathInitialized = true
return path return path
})() })()
return initPromise return tmuxPathInitPromise
} }
export function getCachedTmuxPath(): string | null { export function getCachedTmuxPath(): string | null {
return tmuxPath return tmuxPath
} }
export function startBackgroundCheck(): void { export async function getCmuxPath(): Promise<string | null> {
if (!initPromise) { if (cmuxPathInitialized) {
initPromise = getTmuxPath() return cmuxPath
initPromise.catch(() => {}) }
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 = options.environment
? await findTmuxPath(environment)
: 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 = options.environment
? await findCmuxPath(environment)
: 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
?? (options.environment ? await findCmuxPath(environment) : 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

@@ -0,0 +1,162 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import {
resetResolvedMultiplexerRuntimeForTesting,
setResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../../shared/tmux"
import { createInteractiveBashTool, interactive_bash } from "./tools"
import * as tmuxPathResolver from "./tmux-path-resolver"
const mockToolContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
abort: new AbortController().signal,
}
function createTmuxEnabledRuntime(): ResolvedMultiplexer {
return {
platform: process.platform,
mode: "tmux-only",
paneBackend: "tmux",
notificationBackend: "desktop",
tmux: {
path: "/usr/bin/tmux",
reachable: true,
insideEnvironment: true,
paneId: "%1",
explicitDisable: false,
},
cmux: {
path: null,
reachable: false,
notifyCapable: false,
socketPath: undefined,
endpointType: "missing",
workspaceId: undefined,
surfaceId: undefined,
hintStrength: "none",
explicitDisable: false,
},
}
}
function createPaneUnavailableRuntime(): ResolvedMultiplexer {
return {
platform: process.platform,
mode: "cmux-notify-only",
paneBackend: "none",
notificationBackend: "cmux",
tmux: {
path: "/usr/bin/tmux",
reachable: false,
insideEnvironment: false,
paneId: undefined,
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,
},
}
}
describe("interactive_bash runtime resolution", () => {
afterEach(() => {
resetResolvedMultiplexerRuntimeForTesting()
})
test("createInteractiveBashTool without runtime resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
const tool = createInteractiveBashTool()
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await tool.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).toBe("Error: tmux executable is not reachable")
} finally {
getTmuxPathSpy.mockRestore()
}
})
test("interactive_bash singleton resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await interactive_bash.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).toBe("Error: tmux executable is not reachable")
} finally {
getTmuxPathSpy.mockRestore()
}
})
test("allows detached new-session commands when pane control is unavailable", async () => {
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
const result = await tool.execute(
{ tmux_command: "new-session -d -s omo-dev" },
mockToolContext,
)
expect(result).toBe("Error: tmux executable is not reachable")
expect(getTmuxPathSpy).toHaveBeenCalledTimes(1)
} finally {
getTmuxPathSpy.mockRestore()
}
})
test("allows targeted tmux commands when pane control is unavailable", async () => {
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
const result = await tool.execute(
{ tmux_command: "send-keys -t omo-dev \"vim\" Enter" },
mockToolContext,
)
expect(result).toBe("Error: tmux executable is not reachable")
expect(getTmuxPathSpy).toHaveBeenCalledTimes(1)
} finally {
getTmuxPathSpy.mockRestore()
}
})
test("blocks untargeted pane-control commands when pane backend is unavailable", async () => {
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
try {
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
const result = await tool.execute(
{ tmux_command: "send-keys \"vim\" Enter" },
mockToolContext,
)
expect(result).toBe(
"Error: interactive_bash is TMUX-only and pane control is unavailable in 'cmux-notify-only' runtime.",
)
expect(getTmuxPathSpy).toHaveBeenCalledTimes(0)
} finally {
getTmuxPathSpy.mockRestore()
}
})
})

View File

@@ -1,7 +1,12 @@
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 { 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 { getTmuxPath } from "./tmux-path-resolver"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../../shared/tmux"
/** /**
* Quote-aware command tokenizer with escape handling * Quote-aware command tokenizer with escape handling
@@ -48,14 +53,57 @@ export function tokenizeCommand(cmd: string): string[] {
return tokens return tokens
} }
export const interactive_bash: ToolDefinition = tool({ function hasTmuxTargetFlag(tokens: string[]): boolean {
return tokens.some((token, index) => {
if (token === "-t") {
return typeof tokens[index + 1] === "string" && tokens[index + 1].length > 0
}
return token.startsWith("-t") && token.length > 2
})
}
function hasDetachedFlag(tokens: string[]): boolean {
return tokens.some((token) => {
if (token === "-d") {
return true
}
if (!token.startsWith("-") || token.startsWith("--")) {
return false
}
return token.slice(1).includes("d")
})
}
function canRunWithoutPaneControl(tokens: string[]): boolean {
const subcommand = tokens[0]?.toLowerCase()
if (!subcommand) {
return false
}
const isDetachedNewSession =
(subcommand === "new-session" || subcommand === "new")
&& hasDetachedFlag(tokens)
return isDetachedNewSession || hasTmuxTargetFlag(tokens)
}
export function createInteractiveBashTool(
runtime?: ResolvedMultiplexer,
): ToolDefinition {
return tool({
description: INTERACTIVE_BASH_DESCRIPTION, description: INTERACTIVE_BASH_DESCRIPTION,
args: { args: {
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"), tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
}, },
execute: async (args) => { execute: async (args) => {
try { try {
const tmuxPath = getCachedTmuxPath() ?? "tmux" const resolvedRuntime =
runtime
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
const parts = tokenizeCommand(args.tmux_command) const parts = tokenizeCommand(args.tmux_command)
@@ -63,6 +111,15 @@ export const interactive_bash: ToolDefinition = tool({
return "Error: Empty tmux command" return "Error: Empty tmux command"
} }
if (resolvedRuntime.paneBackend !== "tmux" && !canRunWithoutPaneControl(parts)) {
return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${resolvedRuntime.mode}' runtime.`
}
const tmuxPath = await getTmuxPath()
if (!tmuxPath) {
return "Error: tmux executable is not reachable"
}
const subcommand = parts[0].toLowerCase() const subcommand = parts[0].toLowerCase()
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) { if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t")) const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
@@ -133,4 +190,7 @@ The Bash tool can execute these commands directly. Do NOT retry with interactive
return `Error: ${e instanceof Error ? e.message : String(e)}` return `Error: ${e instanceof Error ? e.message : String(e)}`
} }
}, },
}) })
}
export const interactive_bash: ToolDefinition = createInteractiveBashTool()