diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index be936436a..86517ab31 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -576,9 +576,9 @@ 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 | +| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux (if capable), else desktop | | `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop | -| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux | +| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux (if capable), else desktop | | `none` | Neither tmux nor cmux available | none | desktop | #### Backend Precedence Semantics @@ -601,7 +601,7 @@ The runtime probes cmux availability using these signals: **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. +**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 @@ -615,7 +615,7 @@ The runtime probes cmux availability using these signals: #### Behavior Boundaries -- **Notifications**: Delivered via `cmux notify` when a live cmux endpoint is detected +- **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 diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index f252b3efe..5edd23dd8 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -3,6 +3,7 @@ import type { TmuxConfig } from '../../config/schema' import type { WindowState, PaneAction } from './types' import type { ActionResult, ExecuteContext } from './action-executor' import type { TmuxUtilDeps } from './manager' +import type { ResolvedMultiplexer } from '../../shared/tmux' import * as sharedModule from '../../shared' type ExecuteActionsResult = { @@ -40,6 +41,8 @@ const mockTmuxDeps: TmuxUtilDeps = { getCurrentPaneId: mockGetCurrentPaneId, } +let mockedResolvedMultiplexerRuntime: ResolvedMultiplexer | null = null + mock.module('./pane-state-querier', () => ({ queryWindowState: mockQueryWindowState, paneExists: mockPaneExists, @@ -61,8 +64,13 @@ mock.module('./action-executor', () => ({ mock.module('../../shared/tmux', () => { 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') return { + createDisabledMultiplexerRuntime, + getResolvedMultiplexerRuntime: () => mockedResolvedMultiplexerRuntime, isInsideTmux, getCurrentPaneId, POLL_INTERVAL_BACKGROUND_MS, @@ -135,6 +143,7 @@ describe('TmuxSessionManager', () => { mockExecuteAction.mockClear() mockIsInsideTmux.mockClear() mockGetCurrentPaneId.mockClear() + mockedResolvedMultiplexerRuntime = null trackedSessions.clear() mockQueryWindowState.mockImplementation(async () => createWindowState()) @@ -227,6 +236,54 @@ describe('TmuxSessionManager', () => { 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 () => { // given mockIsInsideTmux.mockReturnValue(true) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index d7ca58e63..f9248e0a6 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -4,7 +4,6 @@ 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, @@ -115,7 +114,7 @@ export class TmuxSessionManager { if (isTmuxUtilDeps(runtimeOrDeps)) { this.deps = runtimeOrDeps - this.resolvedMultiplexer = getResolvedMultiplexerRuntime() ?? createRuntimeFromLegacyDeps(runtimeOrDeps) + this.resolvedMultiplexer = createRuntimeFromLegacyDeps(runtimeOrDeps) } else { this.deps = deps this.resolvedMultiplexer = runtimeOrDeps diff --git a/src/hooks/cmux-notification-adapter.test.ts b/src/hooks/cmux-notification-adapter.test.ts index cb73d94dc..f260cfb11 100644 --- a/src/hooks/cmux-notification-adapter.test.ts +++ b/src/hooks/cmux-notification-adapter.test.ts @@ -104,11 +104,11 @@ describe("cmux notification adapter", () => { expect(adapter.hasDowngraded()).toBe(true) }) - test("falls back to desktop on connection-refused failures", async () => { + test("falls back to desktop when output reports connection-refused", async () => { const adapter = createCmuxNotificationAdapter({ runtime: createResolvedMultiplexer(), executeCommand: async () => createResult({ - exitCode: 1, + exitCode: 0, stderr: "dial tcp 127.0.0.1:7777: connect: connection refused", }), }) diff --git a/src/hooks/cmux-notification-adapter.ts b/src/hooks/cmux-notification-adapter.ts index ed78d9b19..acbd3b4f4 100644 --- a/src/hooks/cmux-notification-adapter.ts +++ b/src/hooks/cmux-notification-adapter.ts @@ -115,15 +115,15 @@ function shouldDowngrade(result: CmuxNotifyCommandResult): boolean { return true } - if (result.exitCode === 0) { - return false - } - const combinedOutput = `${result.stderr}\n${result.stdout}` if (isConnectionRefusedText(combinedOutput)) { return true } + if (result.exitCode === 0) { + return false + } + return true } diff --git a/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts b/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts index 0848cdef9..eb71af779 100644 --- a/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts +++ b/src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { createDisabledMultiplexerRuntime, resolveMultiplexerFromProbes, + resolveMultiplexerRuntime, type ResolvedMultiplexer, } from "./multiplexer-runtime" -import type { CmuxRuntimeProbe, TmuxRuntimeProbe } from "../../../tools/interactive-bash/tmux-path-resolver" +import { + resetMultiplexerPathCacheForTesting, + type CmuxRuntimeProbe, + type TmuxRuntimeProbe, +} from "../../../tools/interactive-bash/tmux-path-resolver" function createTmuxProbe(overrides: Partial = {}): TmuxRuntimeProbe { return { @@ -234,4 +239,41 @@ describe("multiplexer runtime resolution", () => { 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() + } + }) }) diff --git a/src/shared/tmux/tmux-utils/multiplexer-runtime.ts b/src/shared/tmux/tmux-utils/multiplexer-runtime.ts index 5ff7b8485..89f0d48f4 100644 --- a/src/shared/tmux/tmux-utils/multiplexer-runtime.ts +++ b/src/shared/tmux/tmux-utils/multiplexer-runtime.ts @@ -78,6 +78,29 @@ function resolveMode(input: { 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, @@ -174,10 +197,19 @@ export async function resolveMultiplexerRuntime( 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 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, diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts index 9e8b4ff1c..f5a504775 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.test.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -1,10 +1,62 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, spyOn, test } from "bun:test" import { classifyCmuxEndpoint, isConnectionRefusedText, + 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() + } + }) +}) + describe("tmux-path-resolver cmux endpoint helpers", () => { test("classifies relay host:port endpoint as relay", () => { expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay") diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index d8becf23c..23b4412d4 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -260,22 +260,37 @@ async function runProbeCommand( } } -function findCommandPath(commandName: string): string | null { +function findCommandPath( + commandName: string, + environment?: Record, +): string | null { try { - const discovered = Bun.which(commandName) + const probeEnvironment = toProbeEnvironment(environment) + const whichOptions = + probeEnvironment.PATH !== undefined + ? { PATH: probeEnvironment.PATH } + : undefined + + const discovered = Bun.which(commandName, whichOptions) return discovered ?? null } catch { return null } } -async function resolveExecutablePath(commandName: string, verifyArgs: string[]): Promise { - const discovered = findCommandPath(commandName) +async function resolveExecutablePath( + commandName: string, + verifyArgs: string[], + environment?: Record, +): Promise { + const discovered = findCommandPath(commandName, environment) if (!discovered) { return null } - const verification = await runProbeCommand([discovered, ...verifyArgs]) + const verification = await runProbeCommand([discovered, ...verifyArgs], { + environment, + }) if (verification.timedOut || verification.exitCode !== 0) { return null } @@ -283,20 +298,20 @@ async function resolveExecutablePath(commandName: string, verifyArgs: string[]): return discovered } -async function findTmuxPath(): Promise { - if (isTruthyFlag(process.env[TMUX_DISABLE_ENV_KEY])) { +async function findTmuxPath(environment: Record = process.env): Promise { + if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) { return null } - return resolveExecutablePath("tmux", ["-V"]) + return resolveExecutablePath("tmux", ["-V"], environment) } -async function findCmuxPath(): Promise { - if (isTruthyFlag(process.env[CMUX_DISABLE_ENV_KEY])) { +async function findCmuxPath(environment: Record = process.env): Promise { + if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) { return null } - return resolveExecutablePath("cmux", ["--help"]) + return resolveExecutablePath("cmux", ["--help"], environment) } export async function getTmuxPath(): Promise { @@ -356,7 +371,9 @@ export async function probeTmuxRuntime(options: ProbeOptions = {}): Promise