From 64a87a78d6d4324383d826ba6c17ce65ec1fba25 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 29 Mar 2026 13:56:31 +0800 Subject: [PATCH] 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. --- docs/reference/configuration.md | 67 +- src/create-hooks.ts | 4 + src/create-managers.ts | 28 +- src/create-tools.ts | 2 +- src/features/background-agent/manager.ts | 21 +- src/features/background-agent/spawner.ts | 30 +- src/features/tmux-subagent/manager.ts | 62 +- src/hooks/cmux-notification-adapter.test.ts | 142 +++++ src/hooks/cmux-notification-adapter.ts | 189 ++++++ src/hooks/session-notification.test.ts | 158 +++++ src/hooks/session-notification.ts | 65 +- src/index.ts | 18 +- src/openclaw/tmux.ts | 51 +- src/plugin/event.ts | 26 +- src/plugin/hooks/create-core-hooks.ts | 12 +- src/plugin/hooks/create-session-hooks.ts | 16 +- src/plugin/tool-registry.ts | 10 +- src/shared/tmux/tmux-utils.ts | 24 +- src/shared/tmux/tmux-utils/environment.ts | 32 +- .../tmux-utils/multiplexer-runtime.test.ts | 237 +++++++ .../tmux/tmux-utils/multiplexer-runtime.ts | 204 ++++++ src/tools/index.ts | 6 +- src/tools/interactive-bash/constants.ts | 2 +- src/tools/interactive-bash/index.ts | 4 +- .../tmux-path-resolver.test.ts | 50 ++ .../interactive-bash/tmux-path-resolver.ts | 599 +++++++++++++++++- src/tools/interactive-bash/tools.ts | 162 ++--- 27 files changed, 2050 insertions(+), 171 deletions(-) create mode 100644 src/hooks/cmux-notification-adapter.test.ts create mode 100644 src/hooks/cmux-notification-adapter.ts create mode 100644 src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts create mode 100644 src/shared/tmux/tmux-utils/multiplexer-runtime.ts create mode 100644 src/tools/interactive-bash/tmux-path-resolver.test.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c7584e728..be936436a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -23,6 +23,7 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr - [Commands](#commands) - [Browser Automation](#browser-automation) - [Tmux Integration](#tmux-integration) + - [Cmux Integration](#cmux-integration) - [Git Master](#git-master) - [Comment Checker](#comment-checker) - [Notification](#notification) @@ -565,6 +566,61 @@ Run background subagents in separate tmux panes. Requires running inside tmux wi | `main_pane_min_width` | `120` | Min main pane columns | | `agent_pane_min_width` | `40` | Min agent pane columns | +### Cmux Integration + +Cmux integration provides notification routing when running inside a cmux workspace. The plugin probes for cmux availability at runtime and selects a notification backend based on live capability detection. + +#### Runtime Model: ResolvedMultiplexer + +The runtime evaluates tmux and cmux availability to determine operating mode: + +| Mode | Conditions | Pane Control | Notifications | +| ------------------ | ---------------------------------------------- | ------------ | ------------- | +| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux | +| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop | +| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux | +| `none` | Neither tmux nor cmux available | none | desktop | + +#### Backend Precedence Semantics + +**Pane Backend**: Tmux is used for pane control when available. Cmux provides notifications only; it does not manage panes. + +**Notification Backend**: +- Cmux-first when the runtime detects a live, notification-capable cmux endpoint +- Silent fallback to desktop notifications on any failure (non-zero exit, timeout, connection refused) +- Once downgraded to desktop, cmux notifications remain disabled for the session + +#### Detection and Probing + +The runtime probes cmux availability using these signals: + +1. **Binary discovery**: Locates `cmux` executable in PATH +2. **Socket path resolution**: Reads `CMUX_SOCKET_PATH` environment variable (unix socket or relay endpoint) +3. **Reachability probe**: Executes `cmux ping` against the resolved endpoint (250ms timeout) +4. **Capability gating**: Executes `cmux notify --help` to verify notification support (300ms timeout) + +**Endpoint types**: Unix domain sockets (`/tmp/cmux.sock`) and relay addresses (`host:port`) are both supported. + +**Hint strength**: Strong hints (both `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` present) preserve cmux-shim mode even in nested tmux environments. Weak hints (e.g., `TERM_PROGRAM=ghostty`) are tolerated but do not override failed probes. + +#### Environment Variables + +| Variable | Description | +| ------------------------------- | ----------------------------------------------------- | +| `CMUX_SOCKET_PATH` | Path to cmux socket (unix) or relay endpoint (host:port) | +| `CMUX_WORKSPACE_ID` | Cmux workspace identifier | +| `CMUX_SURFACE_ID` | Cmux surface identifier | +| `OH_MY_OPENCODE_DISABLE_CMUX` | Set to `1` or `true` to disable cmux integration | +| `OH_MY_OPENCODE_DISABLE_TMUX` | Set to `1` or `true` to disable tmux integration | + +#### Behavior Boundaries + +- **Notifications**: Delivered via `cmux notify` when a live cmux endpoint is detected +- **Pane Control**: Tmux manages panes. Cmux does not create or control panes. +- **Guarantee**: Tmux-compatible pane control remains available when tmux is live + +The `interactive_bash` tool always uses tmux subcommands for pane operations, regardless of cmux availability. + ### Git Master Configure git commit behavior: @@ -970,9 +1026,14 @@ When enabled, two companion hooks are active: `hashline-read-enhancer` (annotate ### Environment Variables -| Variable | Description | -| --------------------- | ----------------------------------------------------------------- | -| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) | +| Variable | Description | +| ------------------------------ | --------------------------------------------------------------------------- | +| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) | +| `CMUX_SOCKET_PATH` | Path to cmux socket (unix) or relay endpoint (host:port) for cmux integration | +| `CMUX_WORKSPACE_ID` | Cmux workspace identifier (enables strong cmux hints) | +| `CMUX_SURFACE_ID` | Cmux surface identifier (enables strong cmux hints) | +| `OH_MY_OPENCODE_DISABLE_CMUX` | Set to `1` or `true` to disable cmux integration | +| `OH_MY_OPENCODE_DISABLE_TMUX` | Set to `1` or `true` to disable tmux integration | ### Provider-Specific diff --git a/src/create-hooks.ts b/src/create-hooks.ts index e49f08c9a..c6bc05366 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -4,6 +4,7 @@ import type { LoadedSkill } from "./features/opencode-skill-loader/types" import type { BackgroundManager } from "./features/background-agent" import type { PluginContext } from "./plugin/types" import type { ModelCacheState } from "./plugin-state" +import type { ResolvedMultiplexer } from "./shared/tmux" import { createCoreHooks } from "./plugin/hooks/create-core-hooks" import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" @@ -34,6 +35,7 @@ export function createHooks(args: { safeHookEnabled: boolean mergedSkills: LoadedSkill[] availableSkills: AvailableSkill[] + resolvedMultiplexer: ResolvedMultiplexer }) { const { ctx, @@ -44,6 +46,7 @@ export function createHooks(args: { safeHookEnabled, mergedSkills, availableSkills, + resolvedMultiplexer, } = args const core = createCoreHooks({ @@ -52,6 +55,7 @@ export function createHooks(args: { modelCacheState, isHookEnabled, safeHookEnabled, + resolvedMultiplexer, }) const continuation = createContinuationHooks({ diff --git a/src/create-managers.ts b/src/create-managers.ts index 81023071c..43bbe08e4 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -10,9 +10,11 @@ import { TmuxSessionManager } from "./features/tmux-subagent" import { registerManagerForCleanup } from "./features/background-agent/process-cleanup" import { createConfigHandler } from "./plugin-handlers" import { log } from "./shared" +import type { ResolvedMultiplexer } from "./shared/tmux" import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health" export type Managers = { + resolvedMultiplexer: ResolvedMultiplexer tmuxSessionManager: TmuxSessionManager backgroundManager: BackgroundManager skillMcpManager: SkillMcpManager @@ -23,13 +25,21 @@ export function createManagers(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig tmuxConfig: TmuxConfig + resolvedMultiplexer: ResolvedMultiplexer modelCacheState: ModelCacheState backgroundNotificationHookEnabled: boolean }): Managers { - const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args + const { + ctx, + pluginConfig, + tmuxConfig, + resolvedMultiplexer, + modelCacheState, + backgroundNotificationHookEnabled, + } = args markServerRunningInProcess() - const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig) + const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, resolvedMultiplexer) registerManagerForCleanup({ shutdown: async () => { @@ -44,13 +54,18 @@ export function createManagers(args: { pluginConfig.background_task, { tmuxConfig, - onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => { - log("[index] onSubagentSessionCreated callback received", { - sessionID: event.sessionID, - parentID: event.parentID, + resolvedMultiplexer, + onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => { + log("[index] onSubagentSessionCreated callback received", { + sessionID: event.sessionID, + parentID: event.parentID, title: event.title, }) + if (resolvedMultiplexer.paneBackend !== "tmux") { + return + } + await tmuxSessionManager.onSessionCreated({ type: "session.created", properties: { @@ -84,6 +99,7 @@ export function createManagers(args: { }) return { + resolvedMultiplexer, tmuxSessionManager, backgroundManager, skillMcpManager, diff --git a/src/create-tools.ts b/src/create-tools.ts index 880e0a427..e291ec27b 100644 --- a/src/create-tools.ts +++ b/src/create-tools.ts @@ -22,7 +22,7 @@ export type CreateToolsResult = { export async function createTools(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig - managers: Pick + managers: Pick }): Promise { const { ctx, pluginConfig, managers } = args diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index d35428441..75aba3cb5 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -20,7 +20,11 @@ import { setSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" -import { isInsideTmux } from "../../shared/tmux" +import { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime, +} from "../../shared/tmux" +import type { ResolvedMultiplexer } from "../../shared/tmux" import { shouldRetryError, hasMoreFallbacks, @@ -141,6 +145,7 @@ export class BackgroundManager { private shutdownTriggered = false private config?: BackgroundTaskConfig private tmuxEnabled: boolean + private resolvedMultiplexer: ResolvedMultiplexer private onSubagentSessionCreated?: OnSubagentSessionCreated private onShutdown?: () => void | Promise @@ -161,6 +166,7 @@ export class BackgroundManager { config?: BackgroundTaskConfig, options?: { tmuxConfig?: TmuxConfig + resolvedMultiplexer?: ResolvedMultiplexer onSubagentSessionCreated?: OnSubagentSessionCreated onShutdown?: () => void | Promise enableParentSessionNotifications?: boolean @@ -175,6 +181,10 @@ export class BackgroundManager { this.concurrencyManager = new ConcurrencyManager(config) this.config = config this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false + this.resolvedMultiplexer = + options?.resolvedMultiplexer + ?? getResolvedMultiplexerRuntime() + ?? createDisabledMultiplexerRuntime() this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.onShutdown = options?.onShutdown this.rootDescendantCounts = new Map() @@ -455,12 +465,17 @@ export class BackgroundManager { log("[background-agent] tmux callback check", { hasCallback: !!this.onSubagentSessionCreated, tmuxEnabled: this.tmuxEnabled, - isInsideTmux: isInsideTmux(), + paneBackend: this.resolvedMultiplexer.paneBackend, + multiplexerMode: this.resolvedMultiplexer.mode, sessionID, parentID: input.parentSessionID, }) - if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { + if ( + this.onSubagentSessionCreated + && this.tmuxEnabled + && this.resolvedMultiplexer.paneBackend === "tmux" + ) { log("[background-agent] Invoking tmux callback NOW", { sessionID }) await this.onSubagentSessionCreated({ sessionID, diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index e8fc49e32..6cce1295f 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -5,7 +5,11 @@ import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createIn import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" -import { isInsideTmux } from "../../shared/tmux" +import { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime, +} from "../../shared/tmux" +import type { ResolvedMultiplexer } from "../../shared/tmux" import type { ConcurrencyManager } from "./concurrency" export interface SpawnerContext { @@ -13,6 +17,7 @@ export interface SpawnerContext { directory: string concurrencyManager: ConcurrencyManager tmuxEnabled: boolean + resolvedMultiplexer?: ResolvedMultiplexer onSubagentSessionCreated?: OnSubagentSessionCreated onTaskError: (task: BackgroundTask, error: Error) => void } @@ -38,7 +43,19 @@ export async function startTask( ctx: SpawnerContext ): Promise { const { task, input } = item - const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx + const { + client, + directory, + concurrencyManager, + tmuxEnabled, + resolvedMultiplexer, + onSubagentSessionCreated, + onTaskError, + } = ctx + const multiplexerRuntime = + resolvedMultiplexer + ?? getResolvedMultiplexerRuntime() + ?? createDisabledMultiplexerRuntime() log("[background-agent] Starting task:", { taskId: task.id, @@ -83,12 +100,17 @@ export async function startTask( log("[background-agent] tmux callback check", { hasCallback: !!onSubagentSessionCreated, tmuxEnabled, - isInsideTmux: isInsideTmux(), + paneBackend: multiplexerRuntime.paneBackend, + multiplexerMode: multiplexerRuntime.mode, sessionID, parentID: input.parentSessionID, }) - if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) { + if ( + onSubagentSessionCreated + && tmuxEnabled + && multiplexerRuntime.paneBackend === "tmux" + ) { log("[background-agent] Invoking tmux callback NOW", { sessionID }) await onSubagentSessionCreated({ sessionID, diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index ca329ac3c..d7ca58e63 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -3,12 +3,15 @@ import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig, WindowState } from "./types" import { log, normalizeSDKResponse } from "../../shared" import { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime, isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, POLL_INTERVAL_BACKGROUND_MS, SESSION_READY_POLL_INTERVAL_MS, SESSION_READY_TIMEOUT_MS, } from "../../shared/tmux" +import type { ResolvedMultiplexer } from "../../shared/tmux" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { executeActions, executeAction } from "./action-executor" @@ -32,6 +35,38 @@ export interface TmuxUtilDeps { getCurrentPaneId: () => string | undefined } +function isTmuxUtilDeps(value: unknown): value is TmuxUtilDeps { + if (!value || typeof value !== "object") { + return false + } + + const candidate = value as Partial + return ( + typeof candidate.isInsideTmux === "function" + && typeof candidate.getCurrentPaneId === "function" + ) +} + +function createRuntimeFromLegacyDeps(deps: TmuxUtilDeps): ResolvedMultiplexer { + const runtime = createDisabledMultiplexerRuntime() + const insideTmux = deps.isInsideTmux() + if (!insideTmux) { + return runtime + } + + return { + ...runtime, + mode: "tmux-only", + paneBackend: "tmux", + tmux: { + ...runtime.tmux, + reachable: true, + insideEnvironment: true, + paneId: deps.getCurrentPaneId(), + }, + } +} + const defaultTmuxDeps: TmuxUtilDeps = { isInsideTmux: defaultIsInsideTmux, getCurrentPaneId: defaultGetCurrentPaneId, @@ -67,11 +102,25 @@ export class TmuxSessionManager { private deferredAttachTickScheduled = false private nullStateCount = 0 private deps: TmuxUtilDeps + private resolvedMultiplexer: ResolvedMultiplexer private pollingManager: TmuxPollingManager - constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { + constructor( + ctx: PluginInput, + tmuxConfig: TmuxConfig, + runtimeOrDeps: ResolvedMultiplexer | TmuxUtilDeps = createDisabledMultiplexerRuntime(), + deps: TmuxUtilDeps = defaultTmuxDeps, + ) { this.client = ctx.client this.tmuxConfig = tmuxConfig - this.deps = deps + + if (isTmuxUtilDeps(runtimeOrDeps)) { + this.deps = runtimeOrDeps + this.resolvedMultiplexer = getResolvedMultiplexerRuntime() ?? createRuntimeFromLegacyDeps(runtimeOrDeps) + } else { + this.deps = deps + this.resolvedMultiplexer = runtimeOrDeps + } + const defaultPort = process.env.OPENCODE_PORT ?? "4096" const fallbackUrl = `http://localhost:${defaultPort}` try { @@ -86,7 +135,7 @@ export class TmuxSessionManager { } catch { this.serverUrl = fallbackUrl } - this.sourcePaneId = deps.getCurrentPaneId() + this.sourcePaneId = this.resolvedMultiplexer.tmux.paneId ?? this.deps.getCurrentPaneId() this.pollingManager = new TmuxPollingManager( this.client, this.sessions, @@ -97,10 +146,12 @@ export class TmuxSessionManager { tmuxConfig: this.tmuxConfig, serverUrl: this.serverUrl, sourcePaneId: this.sourcePaneId, + multiplexerMode: this.resolvedMultiplexer.mode, + paneBackend: this.resolvedMultiplexer.paneBackend, }) } private isEnabled(): boolean { - return this.tmuxConfig.enabled && this.deps.isInsideTmux() + return this.tmuxConfig.enabled && this.resolvedMultiplexer.paneBackend === "tmux" } private getCapacityConfig(): CapacityConfig { @@ -440,7 +491,8 @@ export class TmuxSessionManager { log("[tmux-session-manager] onSessionCreated called", { enabled, tmuxConfigEnabled: this.tmuxConfig.enabled, - isInsideTmux: this.deps.isInsideTmux(), + isInsideTmux: this.resolvedMultiplexer.paneBackend === "tmux", + multiplexerMode: this.resolvedMultiplexer.mode, eventType: event.type, infoId: event.properties?.info?.id, infoParentID: event.properties?.info?.parentID, diff --git a/src/hooks/cmux-notification-adapter.test.ts b/src/hooks/cmux-notification-adapter.test.ts new file mode 100644 index 000000000..cb73d94dc --- /dev/null +++ b/src/hooks/cmux-notification-adapter.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test" +import { + createCmuxNotificationAdapter, + type CmuxNotifyCommandResult, +} from "./cmux-notification-adapter" +import type { ResolvedMultiplexer } from "../shared/tmux" + +function createResolvedMultiplexer(): ResolvedMultiplexer { + return { + platform: "darwin", + mode: "cmux-shim", + paneBackend: "tmux", + notificationBackend: "cmux", + tmux: { + path: "/usr/bin/tmux", + reachable: true, + insideEnvironment: true, + paneId: "%1", + explicitDisable: false, + }, + cmux: { + path: "/usr/local/bin/cmux", + reachable: true, + notifyCapable: true, + socketPath: "/tmp/cmux.sock", + endpointType: "unix", + workspaceId: "workspace-1", + surfaceId: "surface-1", + hintStrength: "strong", + explicitDisable: false, + }, + } +} + +function createResult(overrides: Partial = {}): CmuxNotifyCommandResult { + return { + exitCode: 0, + stdout: "", + stderr: "", + timedOut: false, + ...overrides, + } +} + +describe("cmux notification adapter", () => { + test("delivers via cmux when notify command succeeds", async () => { + let callCount = 0 + let receivedArgs: string[] = [] + const adapter = createCmuxNotificationAdapter({ + runtime: createResolvedMultiplexer(), + executeCommand: async (input) => { + callCount += 1 + receivedArgs = input.args + return createResult() + }, + }) + + const delivered = await adapter.send("OpenCode", "Task complete") + + expect(delivered).toBe(true) + expect(callCount).toBe(1) + expect(receivedArgs).toEqual([ + "/usr/local/bin/cmux", + "notify", + "--title", + "OpenCode", + "--body", + "Task complete", + "--workspace", + "workspace-1", + "--surface", + "surface-1", + ]) + expect(adapter.hasDowngraded()).toBe(false) + }) + + test("falls back to desktop when cmux notify exits non-zero", async () => { + const adapter = createCmuxNotificationAdapter({ + runtime: createResolvedMultiplexer(), + executeCommand: async () => createResult({ + exitCode: 2, + stderr: "notify failed", + }), + }) + + const delivered = await adapter.send("OpenCode", "Task complete") + + expect(delivered).toBe(false) + expect(adapter.hasDowngraded()).toBe(true) + }) + + test("falls back to desktop when cmux notify times out", async () => { + const adapter = createCmuxNotificationAdapter({ + runtime: createResolvedMultiplexer(), + executeCommand: async () => createResult({ + timedOut: true, + exitCode: null, + }), + }) + + const delivered = await adapter.send("OpenCode", "Task complete") + + expect(delivered).toBe(false) + expect(adapter.hasDowngraded()).toBe(true) + }) + + test("falls back to desktop on connection-refused failures", async () => { + const adapter = createCmuxNotificationAdapter({ + runtime: createResolvedMultiplexer(), + executeCommand: async () => createResult({ + exitCode: 1, + stderr: "dial tcp 127.0.0.1:7777: connect: connection refused", + }), + }) + + const delivered = await adapter.send("OpenCode", "Task complete") + + expect(delivered).toBe(false) + expect(adapter.hasDowngraded()).toBe(true) + }) + + test("downgrades permanently after first cmux notify failure", async () => { + let callCount = 0 + const adapter = createCmuxNotificationAdapter({ + runtime: createResolvedMultiplexer(), + executeCommand: async () => { + callCount += 1 + return createResult({ + exitCode: 1, + stderr: "notify failed", + }) + }, + }) + + const firstDelivered = await adapter.send("OpenCode", "First") + const secondDelivered = await adapter.send("OpenCode", "Second") + + expect(firstDelivered).toBe(false) + expect(secondDelivered).toBe(false) + expect(callCount).toBe(1) + }) +}) diff --git a/src/hooks/cmux-notification-adapter.ts b/src/hooks/cmux-notification-adapter.ts new file mode 100644 index 000000000..ed78d9b19 --- /dev/null +++ b/src/hooks/cmux-notification-adapter.ts @@ -0,0 +1,189 @@ +import { spawn } from "bun" +import type { ResolvedMultiplexer } from "../shared/tmux" +import { isConnectionRefusedText } from "../tools/interactive-bash/tmux-path-resolver" + +const DEFAULT_NOTIFY_TIMEOUT_MS = 1200 + +export interface CmuxNotifyCommandResult { + exitCode: number | null + stdout: string + stderr: string + timedOut: boolean +} + +export type CmuxNotifyCommandExecutor = (input: { + args: string[] + environment: Record + timeoutMs: number +}) => Promise + +export interface CmuxNotificationAdapter { + canSendViaCmux: () => boolean + hasDowngraded: () => boolean + send: (title: string, message: string) => Promise +} + +function toCommandEnvironment( + runtime: ResolvedMultiplexer, + environment: Record, +): Record { + const merged: Record = { + ...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 = {} + 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 + timeoutMs: number +}): Promise { + const proc = spawn(input.args, { + stdout: "pipe", + stderr: "pipe", + env: input.environment, + }) + + let timeoutHandle: ReturnType | undefined + + const timedOut = await Promise.race([ + proc.exited.then(() => false).catch(() => false), + new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + try { + proc.kill() + } catch { + // ignore + } + resolve(true) + }, input.timeoutMs) + }), + ]) + + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + + const exitCode = timedOut ? null : await proc.exited.catch(() => null) + const stdout = await new Response(proc.stdout).text().catch(() => "") + const stderr = await new Response(proc.stderr).text().catch(() => "") + + return { + exitCode, + stdout, + stderr, + timedOut, + } +} + +function buildCmuxNotifyArgs(runtime: ResolvedMultiplexer, title: string, message: string): string[] { + const cmuxPath = runtime.cmux.path ?? "cmux" + const args: string[] = [cmuxPath, "notify", "--title", title, "--body", message] + + if (runtime.cmux.workspaceId) { + args.push("--workspace", runtime.cmux.workspaceId) + } + + if (runtime.cmux.surfaceId) { + args.push("--surface", runtime.cmux.surfaceId) + } + + return args +} + +function shouldDowngrade(result: CmuxNotifyCommandResult): boolean { + if (result.timedOut) { + return true + } + + if (result.exitCode === 0) { + return false + } + + const combinedOutput = `${result.stderr}\n${result.stdout}` + if (isConnectionRefusedText(combinedOutput)) { + return true + } + + return true +} + +export function createCmuxNotificationAdapter(args: { + runtime: ResolvedMultiplexer + environment?: Record + 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 => { + 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, + } +} diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 11a04b03b..6554290ac 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -3,6 +3,8 @@ import { createSessionNotification } from "./session-notification" import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" import * as utils from "./session-notification-utils" import * as sender from "./session-notification-sender" +import type { ResolvedMultiplexer } from "../shared/tmux" +import type { CmuxNotificationAdapter } from "./cmux-notification-adapter" const originalSetTimeout = globalThis.setTimeout const originalClearTimeout = globalThis.clearTimeout @@ -33,6 +35,42 @@ describe("session-notification", () => { } as any } + function createCmuxRuntime(): ResolvedMultiplexer { + return { + platform: "darwin", + mode: "cmux-shim", + paneBackend: "tmux", + notificationBackend: "cmux", + tmux: { + path: "/usr/bin/tmux", + reachable: true, + insideEnvironment: true, + paneId: "%1", + explicitDisable: false, + }, + cmux: { + path: "/usr/local/bin/cmux", + reachable: true, + notifyCapable: true, + socketPath: "/tmp/cmux.sock", + endpointType: "unix", + workspaceId: "workspace-1", + surfaceId: "surface-1", + hintStrength: "strong", + explicitDisable: false, + }, + } + } + + function createCmuxAdapter(overrides: Partial = {}): CmuxNotificationAdapter { + return { + canSendViaCmux: () => true, + hasDowngraded: () => false, + send: async () => false, + ...overrides, + } + } + beforeEach(() => { jest.useRealTimers() globalThis.setTimeout = originalSetTimeout @@ -387,6 +425,126 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(1) }) + test("routes idle notifications through cmux without desktop fallback when cmux send succeeds", async () => { + const sessionID = "cmux-success" + let cmuxSendCalls = 0 + + const cmuxAdapter = createCmuxAdapter({ + send: async () => { + cmuxSendCalls += 1 + return true + }, + }) + + const hook = createSessionNotification( + createMockPluginInput(), + { + idleConfirmationDelay: 10, + skipIfIncompleteTodos: false, + enforceMainSessionFilter: false, + }, + { + resolvedMultiplexer: createCmuxRuntime(), + cmuxNotificationAdapter: cmuxAdapter, + }, + ) + + await hook({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(cmuxSendCalls).toBe(1) + expect(notificationCalls).toHaveLength(0) + }) + + test("falls back to desktop notification when cmux send fails", async () => { + const sessionID = "cmux-fallback" + let cmuxSendCalls = 0 + + const cmuxAdapter = createCmuxAdapter({ + send: async () => { + cmuxSendCalls += 1 + return false + }, + }) + + const hook = createSessionNotification( + createMockPluginInput(), + { + idleConfirmationDelay: 10, + skipIfIncompleteTodos: false, + enforceMainSessionFilter: false, + }, + { + resolvedMultiplexer: createCmuxRuntime(), + cmuxNotificationAdapter: cmuxAdapter, + }, + ) + + await hook({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(cmuxSendCalls).toBe(1) + expect(notificationCalls).toHaveLength(1) + }) + + test("suppresses duplicate idle notifications while using cmux backend", async () => { + const sessionID = "cmux-duplicate" + let cmuxSendCalls = 0 + + const cmuxAdapter = createCmuxAdapter({ + send: async () => { + cmuxSendCalls += 1 + return true + }, + }) + + const hook = createSessionNotification( + createMockPluginInput(), + { + idleConfirmationDelay: 10, + skipIfIncompleteTodos: false, + enforceMainSessionFilter: false, + }, + { + resolvedMultiplexer: createCmuxRuntime(), + cmuxNotificationAdapter: cmuxAdapter, + }, + ) + + await hook({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + await hook({ + event: { + type: "session.idle", + properties: { sessionID }, + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(cmuxSendCalls).toBe(1) + expect(notificationCalls).toHaveLength(0) + }) + function createSenderMockCtx() { const notifyCalls: string[] = [] const mockCtx = { diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index d54d38c61..5948d5cfe 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -10,6 +10,15 @@ import { import * as sessionNotificationSender from "./session-notification-sender" import { hasIncompleteTodos } from "./session-todo-status" import { createIdleNotificationScheduler } from "./session-notification-scheduler" +import { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime, + type ResolvedMultiplexer, +} from "../shared/tmux" +import { + createCmuxNotificationAdapter, + type CmuxNotificationAdapter, +} from "./cmux-notification-adapter" interface SessionNotificationConfig { title?: string @@ -30,10 +39,24 @@ interface SessionNotificationConfig { } export function createSessionNotification( ctx: PluginInput, - config: SessionNotificationConfig = {} + config: SessionNotificationConfig = {}, + options: { + resolvedMultiplexer?: ResolvedMultiplexer + cmuxNotificationAdapter?: CmuxNotificationAdapter + } = {}, ) { const currentPlatform: Platform = sessionNotificationSender.detectPlatform() const defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(currentPlatform) + const resolvedMultiplexer = + options.resolvedMultiplexer + ?? getResolvedMultiplexerRuntime() + ?? createDisabledMultiplexerRuntime() + const cmuxNotificationAdapter = + options.cmuxNotificationAdapter + ?? createCmuxNotificationAdapter({ + runtime: resolvedMultiplexer, + environment: process.env, + }) startBackgroundCheck(currentPlatform) @@ -61,12 +84,18 @@ export function createSessionNotification( typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function" ) { - await sessionNotificationSender.sendSessionNotification( - hookCtx, - platform, + const deliveredViaCmux = await cmuxNotificationAdapter.send( mergedConfig.title, mergedConfig.message, ) + if (!deliveredViaCmux && platform !== "unsupported") { + await sessionNotificationSender.sendSessionNotification( + hookCtx, + platform, + mergedConfig.title, + mergedConfig.message, + ) + } return } @@ -76,6 +105,15 @@ export function createSessionNotification( baseMessage: mergedConfig.message, }) + const deliveredViaCmux = await cmuxNotificationAdapter.send(content.title, content.message) + if (deliveredViaCmux) { + return + } + + if (platform === "unsupported") { + return + } + await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message) }, playSound: sessionNotificationSender.playSessionNotificationSound, @@ -134,7 +172,7 @@ export function createSessionNotification( } return async ({ event }: { event: { type: string; properties?: unknown } }) => { - if (currentPlatform === "unsupported") return + if (currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()) return const props = event.properties as Record | undefined @@ -172,12 +210,18 @@ export function createSessionNotification( if (!shouldNotifyForSession(sessionID)) return scheduler.markSessionActivity(sessionID) - await sessionNotificationSender.sendSessionNotification( - ctx, - currentPlatform, + const deliveredViaCmux = await cmuxNotificationAdapter.send( mergedConfig.title, mergedConfig.permissionMessage, ) + if (!deliveredViaCmux && currentPlatform !== "unsupported") { + await sessionNotificationSender.sendSessionNotification( + ctx, + currentPlatform, + mergedConfig.title, + mergedConfig.permissionMessage, + ) + } if (mergedConfig.playSound && mergedConfig.soundPath) { await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath) } @@ -199,7 +243,10 @@ export function createSessionNotification( ? mergedConfig.permissionMessage : mergedConfig.questionMessage - await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message) + const deliveredViaCmux = await cmuxNotificationAdapter.send(mergedConfig.title, message) + if (!deliveredViaCmux && currentPlatform !== "unsupported") { + await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message) + } if (mergedConfig.playSound && mergedConfig.soundPath) { await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath) } diff --git a/src/index.ts b/src/index.ts index 69fbf60b7..12d1a97b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,14 @@ import { createPluginDispose, type PluginDispose } from "./plugin-dispose" import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" -import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared" +import { + injectServerAuthIntoClient, + log, + logLegacyPluginStartupWarning, + resolveMultiplexerRuntime, + setResolvedMultiplexerRuntime, +} from "./shared" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" -import { startTmuxCheck } from "./tools" let activePluginDispose: PluginDispose | null = null @@ -33,7 +38,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { } injectServerAuthIntoClient(ctx.client) - startTmuxCheck() await activePluginDispose?.() const pluginConfig = loadPluginConfig(ctx.directory, ctx) @@ -54,10 +58,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const modelCacheState = createModelCacheState() + const resolvedMultiplexer = await resolveMultiplexerRuntime({ + environment: process.env, + tmuxEnabled: tmuxConfig.enabled, + }) + setResolvedMultiplexerRuntime(resolvedMultiplexer) + const managers = createManagers({ ctx, pluginConfig, tmuxConfig, + resolvedMultiplexer, modelCacheState, backgroundNotificationHookEnabled: isHookEnabled("background-notification"), }) @@ -77,6 +88,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { safeHookEnabled, mergedSkills: toolsResult.mergedSkills, availableSkills: toolsResult.availableSkills, + resolvedMultiplexer, }) const dispose = createPluginDispose({ diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts index 6b575e662..65a7a53af 100644 --- a/src/openclaw/tmux.ts +++ b/src/openclaw/tmux.ts @@ -1,16 +1,35 @@ import { spawn } from "bun" +import { getTmuxPath } from "../tools/interactive-bash/tmux-path-resolver" +import { + getCurrentPaneId, + getResolvedMultiplexerRuntime, + isInsideTmux, +} from "../shared/tmux" export function getCurrentTmuxSession(): string | null { - const env = process.env.TMUX - if (!env) return null - const match = env.match(/(\d+)$/) - return match ? `session-${match[1]}` : null // Wait, TMUX env is /tmp/tmux-501/default,1234,0 + const resolvedMultiplexer = getResolvedMultiplexerRuntime() + if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") { + return null + } + + if (!isInsideTmux(resolvedMultiplexer ?? undefined)) { + return null + } + + const paneId = getCurrentPaneId(resolvedMultiplexer ?? undefined) + if (!paneId) return null + + const match = paneId.match(/(\d+)$/) + return match ? `session-${match[1]}` : null // Reference tmux.js gets session name via `tmux display-message -p '#S'` } export async function getTmuxSessionName(): Promise { try { - const proc = spawn(["tmux", "display-message", "-p", "#S"], { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) return null + + const proc = spawn([tmuxPath, "display-message", "-p", "#S"], { stdout: "pipe", stderr: "ignore", }) @@ -27,8 +46,11 @@ export async function getTmuxSessionName(): Promise { export async function captureTmuxPane(paneId: string, lines = 15): Promise { try { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) return null + const proc = spawn( - ["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], + [tmuxPath, "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], { stdout: "pipe", stderr: "ignore", @@ -46,7 +68,10 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise { try { - const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) return false + + const literalProc = spawn([tmuxPath, "send-keys", "-t", paneId, "-l", "--", text], { stdout: "ignore", stderr: "ignore", }) @@ -55,7 +80,7 @@ export async function sendToPane(paneId: string, text: string, confirm = true): if (!confirm) return true - const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], { + const enterProc = spawn([tmuxPath, "send-keys", "-t", paneId, "Enter"], { stdout: "ignore", stderr: "ignore", }) @@ -67,8 +92,16 @@ export async function sendToPane(paneId: string, text: string, confirm = true): } export async function isTmuxAvailable(): Promise { + const resolvedMultiplexer = getResolvedMultiplexerRuntime() + if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") { + return false + } + try { - const proc = spawn(["tmux", "-V"], { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) return false + + const proc = spawn([tmuxPath, "-V"], { stdout: "ignore", stderr: "ignore", }) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 52c3f403f..aae68df32 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -341,14 +341,16 @@ export function createEventHandler(args: { firstMessageVariantGate.markSessionCreated(sessionInfo); - await managers.tmuxSessionManager.onSessionCreated( - event as { - type: string; - properties?: { - info?: { id?: string; parentID?: string; title?: string }; - }; - }, - ); + if (managers.resolvedMultiplexer.paneBackend === "tmux") { + await managers.tmuxSessionManager.onSessionCreated( + event as { + type: string; + properties?: { + info?: { id?: string; parentID?: string; title?: string }; + }; + }, + ); + } } if (event.type === "session.deleted") { @@ -376,9 +378,11 @@ export function createEventHandler(args: { deleteSessionTools(sessionInfo.id); await managers.skillMcpManager.disconnectSession(sessionInfo.id); await lspManager.cleanupTempDirectoryClients(); - await managers.tmuxSessionManager.onSessionDeleted({ - sessionID: sessionInfo.id, - }); + if (managers.resolvedMultiplexer.paneBackend === "tmux") { + await managers.tmuxSessionManager.onSessionDeleted({ + sessionID: sessionInfo.id, + }); + } } } diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts index 4bfd2b4b3..4559ed8ec 100644 --- a/src/plugin/hooks/create-core-hooks.ts +++ b/src/plugin/hooks/create-core-hooks.ts @@ -1,6 +1,7 @@ import type { HookName, OhMyOpenCodeConfig } from "../../config" import type { PluginContext } from "../types" import type { ModelCacheState } from "../../plugin-state" +import type { ResolvedMultiplexer } from "../../shared/tmux" import { createSessionHooks } from "./create-session-hooks" import { createToolGuardHooks } from "./create-tool-guard-hooks" @@ -12,8 +13,16 @@ export function createCoreHooks(args: { modelCacheState: ModelCacheState isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean + resolvedMultiplexer: ResolvedMultiplexer }) { - const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args + const { + ctx, + pluginConfig, + modelCacheState, + isHookEnabled, + safeHookEnabled, + resolvedMultiplexer, + } = args const session = createSessionHooks({ ctx, @@ -21,6 +30,7 @@ export function createCoreHooks(args: { modelCacheState, isHookEnabled, safeHookEnabled, + resolvedMultiplexer, }) const tool = createToolGuardHooks({ diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index ccbc8bf0a..5f781809f 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -1,6 +1,7 @@ import type { OhMyOpenCodeConfig, HookName } from "../../config" import type { ModelCacheState } from "../../plugin-state" import type { PluginContext } from "../types" +import type { ResolvedMultiplexer } from "../../shared/tmux" import { createContextWindowMonitorHook, @@ -70,8 +71,16 @@ export function createSessionHooks(args: { modelCacheState: ModelCacheState isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean + resolvedMultiplexer: ResolvedMultiplexer }): SessionHooks { - const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args + const { + ctx, + pluginConfig, + modelCacheState, + isHookEnabled, + safeHookEnabled, + resolvedMultiplexer, + } = args const safeHook = (hookName: HookName, factory: () => T): T | null => safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) @@ -99,7 +108,10 @@ export function createSessionHooks(args: { if (externalNotifier.detected && !forceEnable) { log(getNotificationConflictWarning(externalNotifier.pluginName!)) } else { - sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx)) + sessionNotification = safeHook("session-notification", () => + createSessionNotification(ctx, {}, { + resolvedMultiplexer, + })) } } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index c29ecfce6..d7b03b4a0 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -20,7 +20,7 @@ import { createSessionManagerTools, createDelegateTask, discoverCommandsSync, - interactive_bash, + createInteractiveBashTool, createTaskCreateTool, createTaskGetTool, createTaskList, @@ -100,7 +100,7 @@ function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void { export function createToolRegistry(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig - managers: Pick + managers: Pick skillContext: SkillContext availableCategories: AvailableCategory[] }): ToolRegistryResult { @@ -134,6 +134,10 @@ export function createToolRegistry(args: { availableSkills: skillContext.availableSkills, syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, onSyncSessionCreated: async (event) => { + if (managers.resolvedMultiplexer.paneBackend !== "tmux") { + return + } + log("[index] onSyncSessionCreated callback", { sessionID: event.sessionID, parentID: event.parentID, @@ -202,7 +206,7 @@ export function createToolRegistry(args: { task: delegateTask, skill_mcp: skillMcpTool, skill: skillTool, - interactive_bash, + interactive_bash: createInteractiveBashTool(managers.resolvedMultiplexer), ...taskToolsRecord, ...hashlineToolsRecord, } diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index fe978eadb..242f7895a 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -1,6 +1,28 @@ -export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment" +export { + isInsideTmux, + getCurrentPaneId, + isTmuxPaneControlAvailable, +} from "./tmux-utils/environment" export type { SplitDirection } from "./tmux-utils/environment" +export { + resolveMultiplexerRuntime, + resolveMultiplexerFromProbes, + createDisabledMultiplexerRuntime, + setResolvedMultiplexerRuntime, + getResolvedMultiplexerRuntime, + resetResolvedMultiplexerRuntimeForTesting, +} from "./tmux-utils/multiplexer-runtime" +export type { + MultiplexerMode, + PaneBackend, + NotificationBackend, + ResolvedTmuxRuntime, + ResolvedCmuxRuntime, + ResolvedMultiplexer, + ResolveMultiplexerRuntimeOptions, +} from "./tmux-utils/multiplexer-runtime" + export { isServerRunning, resetServerCheck, markServerRunningInProcess } from "./tmux-utils/server-health" export { getPaneDimensions } from "./tmux-utils/pane-dimensions" diff --git a/src/shared/tmux/tmux-utils/environment.ts b/src/shared/tmux/tmux-utils/environment.ts index 104fcc89b..17a8403c0 100644 --- a/src/shared/tmux/tmux-utils/environment.ts +++ b/src/shared/tmux/tmux-utils/environment.ts @@ -1,13 +1,37 @@ +import type { ResolvedMultiplexer } from "./multiplexer-runtime" +import { getResolvedMultiplexerRuntime } from "./multiplexer-runtime" + export type SplitDirection = "-h" | "-v" +function resolveRuntime(runtime: ResolvedMultiplexer | undefined): ResolvedMultiplexer | null { + return runtime ?? getResolvedMultiplexerRuntime() +} + export function isInsideTmuxEnvironment(environment: Record): boolean { return Boolean(environment.TMUX) } -export function isInsideTmux(): boolean { - return isInsideTmuxEnvironment(process.env) +export function isInsideTmux(runtime?: ResolvedMultiplexer): boolean { + const resolvedRuntime = resolveRuntime(runtime) + if (resolvedRuntime) { + return resolvedRuntime.paneBackend === "tmux" + } + + return isInsideTmuxEnvironment(process.env) } -export function getCurrentPaneId(): string | undefined { - return process.env.TMUX_PANE +export function getCurrentPaneId(runtime?: ResolvedMultiplexer): string | undefined { + const resolvedRuntime = resolveRuntime(runtime) + if (resolvedRuntime) { + if (resolvedRuntime.paneBackend !== "tmux") { + return undefined + } + return resolvedRuntime.tmux.paneId + } + + return process.env.TMUX_PANE +} + +export function isTmuxPaneControlAvailable(runtime?: ResolvedMultiplexer): boolean { + return isInsideTmux(runtime) } diff --git a/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts b/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts new file mode 100644 index 000000000..0848cdef9 --- /dev/null +++ b/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test" +import { + createDisabledMultiplexerRuntime, + resolveMultiplexerFromProbes, + type ResolvedMultiplexer, +} from "./multiplexer-runtime" +import type { CmuxRuntimeProbe, TmuxRuntimeProbe } from "../../../tools/interactive-bash/tmux-path-resolver" + +function createTmuxProbe(overrides: Partial = {}): TmuxRuntimeProbe { + return { + path: "/usr/bin/tmux", + reachable: true, + paneControlReachable: true, + explicitDisable: false, + ...overrides, + } +} + +function createCmuxProbe(overrides: Partial = {}): 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 + platform?: NodeJS.Platform + tmuxEnabled?: boolean + cmuxEnabled?: boolean + tmuxProbe?: Partial + cmuxProbe?: Partial +}): ResolvedMultiplexer { + return resolveMultiplexerFromProbes({ + platform: args.platform ?? "darwin", + environment: { + TMUX: "/tmp/tmux-501/default,999,0", + TMUX_PANE: "%1", + CMUX_SOCKET_PATH: "/tmp/cmux.sock", + CMUX_WORKSPACE_ID: "workspace-1", + CMUX_SURFACE_ID: "surface-1", + TERM_PROGRAM: "ghostty", + ...args.environment, + }, + tmuxEnabled: args.tmuxEnabled ?? true, + cmuxEnabled: args.cmuxEnabled ?? true, + tmuxProbe: createTmuxProbe(args.tmuxProbe), + cmuxProbe: createCmuxProbe(args.cmuxProbe), + }) +} + +describe("multiplexer runtime resolution", () => { + test("resolves cmux-shim when both runtimes are live", () => { + const runtime = resolveRuntime({}) + + expect(runtime.mode).toBe("cmux-shim") + expect(runtime.paneBackend).toBe("tmux") + expect(runtime.notificationBackend).toBe("cmux") + }) + + test("resolves tmux-only when cmux is unreachable", () => { + const runtime = resolveRuntime({ + cmuxProbe: { + reachable: false, + failureKind: "missing-socket", + }, + }) + + expect(runtime.mode).toBe("tmux-only") + expect(runtime.paneBackend).toBe("tmux") + expect(runtime.notificationBackend).toBe("desktop") + }) + + test("resolves cmux-notify-only when tmux pane control is unavailable", () => { + const runtime = resolveRuntime({ + tmuxProbe: { + paneControlReachable: false, + }, + }) + + expect(runtime.mode).toBe("cmux-notify-only") + expect(runtime.paneBackend).toBe("none") + expect(runtime.notificationBackend).toBe("cmux") + }) + + test("resolves none when both runtimes are unavailable", () => { + const runtime = resolveRuntime({ + environment: { + TMUX: undefined, + TMUX_PANE: undefined, + CMUX_SOCKET_PATH: undefined, + }, + tmuxProbe: { + reachable: false, + paneControlReachable: false, + path: null, + }, + cmuxProbe: { + reachable: false, + path: null, + socketPath: undefined, + endpointType: "missing", + hintStrength: "none", + notifyCapable: false, + }, + }) + + expect(runtime.mode).toBe("none") + expect(runtime.paneBackend).toBe("none") + expect(runtime.notificationBackend).toBe("desktop") + }) + + test("keeps cmux-shim for nested tmux-inside-cmux hints", () => { + const runtime = resolveRuntime({ + environment: { + TMUX: "/tmp/tmux-501/default,1001,0", + CMUX_SOCKET_PATH: "/tmp/cmux-nested.sock", + CMUX_WORKSPACE_ID: "workspace-nested", + CMUX_SURFACE_ID: "surface-nested", + }, + }) + + expect(runtime.mode).toBe("cmux-shim") + expect(runtime.cmux.hintStrength).toBe("strong") + }) + + test("downgrades stale cmux socket env to tmux-only", () => { + const runtime = resolveRuntime({ + cmuxProbe: { + reachable: false, + failureKind: "connection-refused", + }, + }) + + expect(runtime.mode).toBe("tmux-only") + expect(runtime.notificationBackend).toBe("desktop") + }) + + test("respects explicit tmux disable and falls to cmux-notify-only", () => { + const runtime = resolveRuntime({ + tmuxEnabled: false, + }) + + expect(runtime.mode).toBe("cmux-notify-only") + expect(runtime.paneBackend).toBe("none") + }) + + test("keeps desktop notifications when cmux is reachable without notify capability", () => { + const runtime = resolveRuntime({ + cmuxProbe: { + notifyCapable: false, + notifyFailureKind: "exit-non-zero", + }, + }) + + expect(runtime.mode).toBe("cmux-shim") + expect(runtime.notificationBackend).toBe("desktop") + }) + + test("treats relay endpoint addresses as valid cmux socket targets", () => { + const runtime = resolveRuntime({ + environment: { + TMUX: undefined, + TMUX_PANE: undefined, + CMUX_SOCKET_PATH: "127.0.0.1:7777", + }, + cmuxProbe: { + endpointType: "relay", + socketPath: "127.0.0.1:7777", + }, + }) + + expect(runtime.mode).toBe("cmux-notify-only") + expect(runtime.cmux.endpointType).toBe("relay") + }) + + test("keeps weak ghostty hint as non-authoritative on non-mac platforms", () => { + const runtime = resolveRuntime({ + platform: "linux", + environment: { + TMUX: undefined, + TMUX_PANE: undefined, + TERM_PROGRAM: "ghostty", + CMUX_SOCKET_PATH: undefined, + }, + tmuxProbe: { + reachable: false, + paneControlReachable: false, + path: null, + }, + cmuxProbe: { + reachable: false, + path: "/usr/local/bin/cmux", + socketPath: undefined, + endpointType: "missing", + hintStrength: "weak", + notifyCapable: false, + failureKind: "missing-socket", + }, + }) + + expect(runtime.mode).toBe("none") + expect(runtime.cmux.hintStrength).toBe("weak") + expect(runtime.platform).toBe("linux") + }) + + test("createDisabledMultiplexerRuntime returns safe defaults", () => { + const runtime = createDisabledMultiplexerRuntime("darwin") + + expect(runtime.mode).toBe("none") + expect(runtime.paneBackend).toBe("none") + expect(runtime.notificationBackend).toBe("desktop") + expect(runtime.cmux.endpointType).toBe("missing") + }) + + test("downgrades stale tmux environment even when tmux binary exists", () => { + const runtime = resolveRuntime({ + tmuxProbe: { + reachable: true, + paneControlReachable: false, + }, + }) + + expect(runtime.mode).toBe("cmux-notify-only") + expect(runtime.paneBackend).toBe("none") + expect(runtime.tmux.reachable).toBe(false) + expect(runtime.tmux.insideEnvironment).toBe(true) + }) +}) diff --git a/src/shared/tmux/tmux-utils/multiplexer-runtime.ts b/src/shared/tmux/tmux-utils/multiplexer-runtime.ts new file mode 100644 index 000000000..5ff7b8485 --- /dev/null +++ b/src/shared/tmux/tmux-utils/multiplexer-runtime.ts @@ -0,0 +1,204 @@ +import { + probeCmuxRuntime, + probeTmuxRuntime, + type CmuxRuntimeProbe, + type CmuxEndpointType, + type CmuxHintStrength, + type TmuxRuntimeProbe, +} from "../../../tools/interactive-bash/tmux-path-resolver" + +export type MultiplexerMode = "cmux-shim" | "tmux-only" | "cmux-notify-only" | "none" +export type PaneBackend = "tmux" | "none" +export type NotificationBackend = "cmux" | "desktop" + +export interface ResolvedTmuxRuntime { + path: string | null + reachable: boolean + insideEnvironment: boolean + paneId: string | undefined + explicitDisable: boolean +} + +export interface ResolvedCmuxRuntime { + path: string | null + reachable: boolean + notifyCapable: boolean + socketPath: string | undefined + endpointType: CmuxEndpointType + workspaceId: string | undefined + surfaceId: string | undefined + hintStrength: CmuxHintStrength + explicitDisable: boolean +} + +export interface ResolvedMultiplexer { + platform: NodeJS.Platform + mode: MultiplexerMode + paneBackend: PaneBackend + notificationBackend: NotificationBackend + tmux: ResolvedTmuxRuntime + cmux: ResolvedCmuxRuntime +} + +export interface ResolveMultiplexerRuntimeOptions { + environment?: Record + 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): boolean { + return Boolean(normalizeEnvValue(environment.TMUX)) +} + +function resolveMode(input: { + hasLiveTmuxPaneControl: boolean + hasLiveCmuxRuntime: boolean +}): MultiplexerMode { + if (input.hasLiveCmuxRuntime && input.hasLiveTmuxPaneControl) { + return "cmux-shim" + } + + if (input.hasLiveTmuxPaneControl) { + return "tmux-only" + } + + if (input.hasLiveCmuxRuntime) { + return "cmux-notify-only" + } + + return "none" +} + +export function createDisabledMultiplexerRuntime(platform: NodeJS.Platform = process.platform): ResolvedMultiplexer { + return { + platform, + mode: "none", + paneBackend: "none", + notificationBackend: "desktop", + tmux: { + path: null, + reachable: false, + insideEnvironment: false, + paneId: undefined, + explicitDisable: false, + }, + cmux: { + path: null, + reachable: false, + notifyCapable: false, + socketPath: undefined, + endpointType: "missing", + workspaceId: undefined, + surfaceId: undefined, + hintStrength: "none", + explicitDisable: false, + }, + } +} + +export function resolveMultiplexerFromProbes(args: { + platform: NodeJS.Platform + environment: Record + 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 { + const environment = options.environment ?? process.env + const platform = options.platform ?? process.platform + const tmuxEnabled = options.tmuxEnabled ?? true + const cmuxEnabled = options.cmuxEnabled ?? true + + const [tmuxProbe, cmuxProbe] = await Promise.all([ + options.tmuxProbe ? Promise.resolve(options.tmuxProbe) : probeTmuxRuntime({ environment }), + options.cmuxProbe ? Promise.resolve(options.cmuxProbe) : probeCmuxRuntime({ environment }), + ]) + + const resolved = resolveMultiplexerFromProbes({ + platform, + environment, + tmuxEnabled, + cmuxEnabled, + tmuxProbe, + cmuxProbe, + }) + + return resolved +} + +export function setResolvedMultiplexerRuntime(runtime: ResolvedMultiplexer): void { + resolvedMultiplexerRuntime = runtime +} + +export function getResolvedMultiplexerRuntime(): ResolvedMultiplexer | null { + return resolvedMultiplexerRuntime +} + +export function resetResolvedMultiplexerRuntimeForTesting(): void { + resolvedMultiplexerRuntime = null +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 9d9bd9c04..dadeb2424 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -19,7 +19,11 @@ export { createSessionManagerTools } from "./session-manager" export { sessionExists } from "./session-manager/storage" -export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash" +export { + interactive_bash, + createInteractiveBashTool, + startBackgroundCheck as startTmuxCheck, +} from "./interactive-bash" export { createSkillMcpTool } from "./skill-mcp" import { diff --git a/src/tools/interactive-bash/constants.ts b/src/tools/interactive-bash/constants.ts index 67570e4c8..797ebc331 100644 --- a/src/tools/interactive-bash/constants.ts +++ b/src/tools/interactive-bash/constants.ts @@ -11,7 +11,7 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [ "pipep", ] -export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This is TMUX ONLY. Pass tmux subcommands directly (without 'tmux' prefix). +export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This remains TMUX ONLY in phase 1 (cmux notify does not add pane control). Pass tmux subcommands directly (without 'tmux' prefix). Examples: new-session -d -s omo-dev, send-keys -t omo-dev "vim" Enter diff --git a/src/tools/interactive-bash/index.ts b/src/tools/interactive-bash/index.ts index 57b4e4f45..83c51c849 100644 --- a/src/tools/interactive-bash/index.ts +++ b/src/tools/interactive-bash/index.ts @@ -1,4 +1,4 @@ -import { interactive_bash } from "./tools" +import { interactive_bash, createInteractiveBashTool } from "./tools" import { startBackgroundCheck } from "./tmux-path-resolver" -export { interactive_bash, startBackgroundCheck } +export { interactive_bash, createInteractiveBashTool, startBackgroundCheck } diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts new file mode 100644 index 000000000..9e8b4ff1c --- /dev/null +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { + classifyCmuxEndpoint, + isConnectionRefusedText, + supportsCmuxNotifyFlagModel, +} from "./tmux-path-resolver" + +describe("tmux-path-resolver cmux endpoint helpers", () => { + test("classifies relay host:port endpoint as relay", () => { + expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay") + }) + + test("classifies unix socket path as unix", () => { + expect(classifyCmuxEndpoint("/tmp/cmux.sock")).toBe("unix") + }) + + test("classifies empty endpoint as missing", () => { + expect(classifyCmuxEndpoint(" ")).toBe("missing") + }) + + test("detects connection refused text", () => { + expect(isConnectionRefusedText("connect: connection refused")).toBe(true) + expect(isConnectionRefusedText("ECONNREFUSED while connecting")).toBe(true) + expect(isConnectionRefusedText("permission denied")).toBe(false) + }) + + test("accepts cmux notify help output with title/body model", () => { + const help = ` +cmux notify + +Flags: + --title + --body +` + + expect(supportsCmuxNotifyFlagModel(help)).toBe(true) + }) + + test("rejects legacy message-based notify flag model", () => { + const legacyHelp = ` +cmux notify + +Flags: + --title + --message +` + + expect(supportsCmuxNotifyFlagModel(legacyHelp)).toBe(false) + }) +}) diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 1aa346235..d8becf23c 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -1,71 +1,608 @@ import { spawn } from "bun" +const DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS = 400 +const DEFAULT_CMUX_PING_TIMEOUT_MS = 250 +const DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS = 300 +const DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS = 250 + +const CMUX_RELAY_ENDPOINT_PATTERN = /^[^\s/:]+:\d+$/ + +const TMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_TMUX" +const CMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_CMUX" + let tmuxPath: string | null = null -let initPromise: Promise | null = null +let tmuxPathInitialized = false +let tmuxPathInitPromise: Promise | null = null -async function findTmuxPath(): Promise { - const isWindows = process.platform === "win32" - const cmd = isWindows ? "where" : "which" +let cmuxPath: string | null = null +let cmuxPathInitialized = false +let cmuxPathInitPromise: Promise | 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 + 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 | undefined, +): Record { + const merged: Record = { + ...process.env, + ...(environment ?? {}), + } + + const normalized: Record = {} + 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): 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 + timeoutMs: number +}): Promise { + 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 + timeoutMs?: number + } = {}, +): Promise { try { - const proc = spawn([cmd, "tmux"], { + const proc = spawn(args, { stdout: "pipe", stderr: "pipe", + env: toProbeEnvironment(options.environment), }) - const exitCode = await proc.exited - if (exitCode !== 0) { - return null + const timeoutMs = options.timeoutMs ?? DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS + let timeoutHandle: ReturnType | undefined + + const timedOut = await Promise.race([ + proc.exited.then(() => false).catch(() => false), + new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + try { + proc.kill() + } catch { + // ignore + } + resolve(true) + }, timeoutMs) + }), + ]) + + if (timeoutHandle) { + clearTimeout(timeoutHandle) } - const stdout = await new Response(proc.stdout).text() - const path = stdout.trim().split("\n")[0] + const exitCode = timedOut ? null : await proc.exited.catch(() => null) + const stdout = await new Response(proc.stdout).text().catch(() => "") + const stderr = await new Response(proc.stderr).text().catch(() => "") - if (!path) { - return null + return { + exitCode, + stdout, + stderr, + timedOut, } - - const verifyProc = spawn([path, "-V"], { - stdout: "pipe", - stderr: "pipe", - }) - - const verifyExitCode = await verifyProc.exited - if (verifyExitCode !== 0) { - return null + } catch { + return { + exitCode: null, + stdout: "", + stderr: "", + timedOut: false, } + } +} - return path +function findCommandPath(commandName: string): string | null { + try { + const discovered = Bun.which(commandName) + return discovered ?? null } catch { return null } } +async function resolveExecutablePath(commandName: string, verifyArgs: string[]): Promise { + const discovered = findCommandPath(commandName) + if (!discovered) { + return null + } + + const verification = await runProbeCommand([discovered, ...verifyArgs]) + if (verification.timedOut || verification.exitCode !== 0) { + return null + } + + return discovered +} + +async function findTmuxPath(): Promise { + if (isTruthyFlag(process.env[TMUX_DISABLE_ENV_KEY])) { + return null + } + + return resolveExecutablePath("tmux", ["-V"]) +} + +async function findCmuxPath(): Promise { + if (isTruthyFlag(process.env[CMUX_DISABLE_ENV_KEY])) { + return null + } + + return resolveExecutablePath("cmux", ["--help"]) +} + export async function getTmuxPath(): Promise { - if (tmuxPath !== null) { + if (tmuxPathInitialized) { return tmuxPath } - if (initPromise) { - return initPromise + if (tmuxPathInitPromise) { + return tmuxPathInitPromise } - initPromise = (async () => { + tmuxPathInitPromise = (async () => { const path = await findTmuxPath() tmuxPath = path + tmuxPathInitialized = true return path })() - return initPromise + return tmuxPathInitPromise } export function getCachedTmuxPath(): string | null { return tmuxPath } -export function startBackgroundCheck(): void { - if (!initPromise) { - initPromise = getTmuxPath() - initPromise.catch(() => {}) +export async function getCmuxPath(): Promise { + if (cmuxPathInitialized) { + return cmuxPath + } + + if (cmuxPathInitPromise) { + return cmuxPathInitPromise + } + + cmuxPathInitPromise = (async () => { + const path = await findCmuxPath() + cmuxPath = path + cmuxPathInitialized = true + return path + })() + + return cmuxPathInitPromise +} + +export function getCachedCmuxPath(): string | null { + return cmuxPath +} + +export async function probeTmuxRuntime(options: ProbeOptions = {}): Promise { + const environment = options.environment ?? process.env + if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) { + return { + path: null, + reachable: false, + paneControlReachable: false, + explicitDisable: true, + } + } + + const path = await getTmuxPath() + const paneId = normalizeEnvValue(environment.TMUX_PANE) + const hasTmuxEnvironment = Boolean(normalizeEnvValue(environment.TMUX)) + + if (!path || !hasTmuxEnvironment || !paneId) { + return { + path, + reachable: Boolean(path), + paneControlReachable: false, + explicitDisable: false, + } + } + + const paneControlReachable = await probeTmuxPaneControl({ + tmuxBinary: path, + paneId, + environment, + timeoutMs: options.timeoutMs ?? DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS, + }) + + return { + path, + reachable: Boolean(path), + paneControlReachable, + explicitDisable: false, } } + +function classifyCmuxProbeFailureKind(result: ProbeCommandResult): CmuxProbeFailureKind { + const combinedOutput = `${result.stderr}\n${result.stdout}` + return isConnectionRefusedText(combinedOutput) + ? "connection-refused" + : "exit-non-zero" +} + +export async function probeCmuxReachability(options: ProbeOptions = {}): Promise { + const environment = options.environment ?? process.env + const socketPath = normalizeEnvValue(environment.CMUX_SOCKET_PATH) + const endpointType = classifyCmuxEndpoint(socketPath) + const workspaceId = normalizeEnvValue(environment.CMUX_WORKSPACE_ID) + const surfaceId = normalizeEnvValue(environment.CMUX_SURFACE_ID) + const hintStrength = resolveCmuxHintStrength(environment) + + if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) { + return { + path: null, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: false, + explicitDisable: true, + failureKind: "explicit-disable", + } + } + + const path = await getCmuxPath() + if (!path) { + return { + path: null, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: false, + explicitDisable: false, + failureKind: "missing-binary", + } + } + + if (!socketPath) { + return { + path, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: false, + explicitDisable: false, + failureKind: "missing-socket", + } + } + + const probeResult = await runProbeCommand([path, "ping"], { + environment, + timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS, + }) + + let effectiveProbeResult = probeResult + + const firstFailureKind = classifyCmuxProbeFailureKind(probeResult) + if ( + !probeResult.timedOut + && probeResult.exitCode !== 0 + && endpointType === "relay" + && firstFailureKind === "connection-refused" + ) { + effectiveProbeResult = await runProbeCommand([path, "ping"], { + environment, + timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS, + }) + } + + if (effectiveProbeResult.timedOut) { + return { + path, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: false, + explicitDisable: false, + failureKind: "timeout", + } + } + + if (effectiveProbeResult.exitCode !== 0) { + const failureKind = classifyCmuxProbeFailureKind(effectiveProbeResult) + + return { + path, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: false, + explicitDisable: false, + failureKind, + } + } + + return { + path, + socketPath, + endpointType, + workspaceId, + surfaceId, + hintStrength, + reachable: true, + explicitDisable: false, + } +} + +export async function probeCmuxNotificationCapability( + options: ProbeOptions & { + cmuxPath?: string | null + workspaceId?: string + surfaceId?: string + } = {}, +): Promise { + const environment = options.environment ?? process.env + + if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) { + return { + capable: false, + explicitDisable: true, + failureKind: "explicit-disable", + } + } + + const cmuxBinary = options.cmuxPath ?? await getCmuxPath() + if (!cmuxBinary) { + return { + capable: false, + explicitDisable: false, + failureKind: "missing-binary", + } + } + + const probeResult = await runProbeCommand(buildCmuxNotifyProbeArgs({ + cmuxBinary, + workspaceId: normalizeEnvValue(options.workspaceId), + surfaceId: normalizeEnvValue(options.surfaceId), + }), { + environment, + timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS, + }) + + if (probeResult.timedOut) { + return { + capable: false, + explicitDisable: false, + failureKind: "timeout", + } + } + + if (probeResult.exitCode !== 0) { + return { + capable: false, + explicitDisable: false, + failureKind: "exit-non-zero", + } + } + + const helpOutput = `${probeResult.stdout}\n${probeResult.stderr}` + if (!supportsCmuxNotifyFlagModel(helpOutput)) { + return { + capable: false, + explicitDisable: false, + failureKind: "unsupported-contract", + } + } + + return { + capable: true, + explicitDisable: false, + } +} + +export async function probeCmuxRuntime(options: ProbeOptions = {}): Promise { + 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 +} diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index a0795ee36..6421e018c 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -1,7 +1,12 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants" -import { getCachedTmuxPath } from "./tmux-path-resolver" +import { getTmuxPath } from "./tmux-path-resolver" +import { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime, + type ResolvedMultiplexer, +} from "../../shared/tmux" /** * Quote-aware command tokenizer with escape handling @@ -48,34 +53,46 @@ export function tokenizeCommand(cmd: string): string[] { return tokens } -export const interactive_bash: ToolDefinition = tool({ - description: INTERACTIVE_BASH_DESCRIPTION, - args: { - tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"), - }, - execute: async (args) => { - try { - const tmuxPath = getCachedTmuxPath() ?? "tmux" - - const parts = tokenizeCommand(args.tmux_command) - - if (parts.length === 0) { - return "Error: Empty tmux command" - } - - const subcommand = parts[0].toLowerCase() - if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) { - const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t")) - let sessionName = "omo-session" - if (sessionIdx !== -1) { - if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) { - sessionName = parts[sessionIdx + 1] - } else if (parts[sessionIdx].startsWith("-t")) { - sessionName = parts[sessionIdx].slice(2) - } +export function createInteractiveBashTool( + runtime: ResolvedMultiplexer = + getResolvedMultiplexerRuntime() + ?? createDisabledMultiplexerRuntime(), +): ToolDefinition { + return tool({ + description: INTERACTIVE_BASH_DESCRIPTION, + args: { + tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"), + }, + execute: async (args) => { + try { + if (runtime.paneBackend !== "tmux") { + return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${runtime.mode}' runtime.` } - return `Error: '${parts[0]}' is blocked in interactive_bash. + const tmuxPath = await getTmuxPath() + if (!tmuxPath) { + return "Error: tmux executable is not reachable" + } + + const parts = tokenizeCommand(args.tmux_command) + + if (parts.length === 0) { + return "Error: Empty tmux command" + } + + const subcommand = parts[0].toLowerCase() + if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) { + const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t")) + let sessionName = "omo-session" + if (sessionIdx !== -1) { + if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) { + sessionName = parts[sessionIdx + 1] + } else if (parts[sessionIdx].startsWith("-t")) { + sessionName = parts[sessionIdx].slice(2) + } + } + + return `Error: '${parts[0]}' is blocked in interactive_bash. **USE BASH TOOL INSTEAD:** @@ -88,49 +105,52 @@ tmux capture-pane -p -t ${sessionName} -S -1000 \`\`\` The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.` + } + + const proc = spawnWithWindowsHide([tmuxPath, ...parts], { + stdout: "pipe", + stderr: "pipe", + }) + + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`) + try { + proc.kill() + // Fire-and-forget: wait for process exit in background to avoid zombies + void proc.exited.catch(() => {}) + } catch { + // Ignore kill errors; we'll still reject with timeoutError below + } + reject(timeoutError) + }, DEFAULT_TIMEOUT_MS) + proc.exited + .then(() => clearTimeout(id)) + .catch(() => clearTimeout(id)) + }) + + // Read stdout and stderr in parallel to avoid race conditions + const [stdout, stderr, exitCode] = await Promise.race([ + Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]), + timeoutPromise, + ]) + + // Check exitCode properly - return error even if stderr is empty + if (exitCode !== 0) { + const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}` + return `Error: ${errorMsg}` + } + + return stdout || "(no output)" + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` } + }, + }) +} - const proc = spawnWithWindowsHide([tmuxPath, ...parts], { - stdout: "pipe", - stderr: "pipe", - }) - - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`) - try { - proc.kill() - // Fire-and-forget: wait for process exit in background to avoid zombies - void proc.exited.catch(() => {}) - } catch { - // Ignore kill errors; we'll still reject with timeoutError below - } - reject(timeoutError) - }, DEFAULT_TIMEOUT_MS) - proc.exited - .then(() => clearTimeout(id)) - .catch(() => clearTimeout(id)) - }) - - // Read stdout and stderr in parallel to avoid race conditions - const [stdout, stderr, exitCode] = await Promise.race([ - Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]), - timeoutPromise, - ]) - - // Check exitCode properly - return error even if stderr is empty - if (exitCode !== 0) { - const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}` - return `Error: ${errorMsg}` - } - - return stdout || "(no output)" - } catch (e) { - return `Error: ${e instanceof Error ? e.message : String(e)}` - } - }, -}) +export const interactive_bash: ToolDefinition = createInteractiveBashTool()