Compare commits
6 Commits
dev
...
feat/cmux-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b02e2c1a0 | ||
|
|
6ffadaaa51 | ||
|
|
7088120045 | ||
|
|
f7ac464194 | ||
|
|
73f5ae968f | ||
|
|
64a87a78d6 |
@@ -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 (if capable), else desktop |
|
||||
| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop |
|
||||
| `cmux-notify-only` | Live cmux, tmux pane control unavailable | none | cmux (if capable), else desktop |
|
||||
| `none` | Neither tmux nor cmux available | none | desktop |
|
||||
|
||||
#### Backend Precedence Semantics
|
||||
|
||||
**Pane Backend**: Tmux is used for pane control when available. Cmux provides notifications only; it does not manage panes.
|
||||
|
||||
**Notification Backend**:
|
||||
- Cmux-first when the runtime detects a live, notification-capable cmux endpoint
|
||||
- Silent fallback to desktop notifications on any failure (non-zero exit, timeout, connection refused)
|
||||
- Once downgraded to desktop, cmux notifications remain disabled for the session
|
||||
|
||||
#### Detection and Probing
|
||||
|
||||
The runtime probes cmux availability using these signals:
|
||||
|
||||
1. **Binary discovery**: Locates `cmux` executable in PATH
|
||||
2. **Socket path resolution**: Reads `CMUX_SOCKET_PATH` environment variable (unix socket or relay endpoint)
|
||||
3. **Reachability probe**: Executes `cmux ping` against the resolved endpoint (250ms timeout)
|
||||
4. **Capability gating**: Executes `cmux notify --help` to verify notification support (300ms timeout)
|
||||
|
||||
**Endpoint types**: Unix domain sockets (`/tmp/cmux.sock`) and relay addresses (`host:port`) are both supported.
|
||||
|
||||
**Hint strength**: Strong hints (both `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` present) and weak hints (e.g., `TERM_PROGRAM=ghostty`) are recorded in the runtime metadata but do not affect mode resolution or probe results.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------- | ----------------------------------------------------- |
|
||||
| `CMUX_SOCKET_PATH` | Path to cmux socket (unix) or relay endpoint (host:port) |
|
||||
| `CMUX_WORKSPACE_ID` | Cmux workspace identifier |
|
||||
| `CMUX_SURFACE_ID` | Cmux surface identifier |
|
||||
| `OH_MY_OPENCODE_DISABLE_CMUX` | Set to `1` or `true` to disable cmux integration |
|
||||
| `OH_MY_OPENCODE_DISABLE_TMUX` | Set to `1` or `true` to disable tmux integration |
|
||||
|
||||
#### Behavior Boundaries
|
||||
|
||||
- **Notifications**: Delivered via `cmux notify` when a live, notification-capable cmux endpoint is detected
|
||||
- **Pane Control**: Tmux manages panes. Cmux does not create or control panes.
|
||||
- **Guarantee**: Tmux-compatible pane control remains available when tmux is live
|
||||
|
||||
The `interactive_bash` tool always uses tmux subcommands for pane operations, regardless of cmux availability.
|
||||
|
||||
### Git Master
|
||||
|
||||
Configure git commit behavior:
|
||||
@@ -971,8 +1027,13 @@ 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) |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,6 +54,7 @@ export function createManagers(args: {
|
||||
pluginConfig.background_task,
|
||||
{
|
||||
tmuxConfig,
|
||||
resolvedMultiplexer,
|
||||
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
|
||||
log("[index] onSubagentSessionCreated callback received", {
|
||||
sessionID: event.sessionID,
|
||||
@@ -51,6 +62,10 @@ export function createManagers(args: {
|
||||
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,
|
||||
|
||||
@@ -22,7 +22,7 @@ export type CreateToolsResult = {
|
||||
export async function createTools(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
||||
managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
||||
}): Promise<CreateToolsResult> {
|
||||
const { ctx, pluginConfig, managers } = args
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import {
|
||||
createDisabledMultiplexerRuntime,
|
||||
getResolvedMultiplexerRuntime,
|
||||
} from "../../shared/tmux"
|
||||
import type { ResolvedMultiplexer } from "../../shared/tmux"
|
||||
import {
|
||||
shouldRetryError,
|
||||
hasMoreFallbacks,
|
||||
@@ -141,6 +145,7 @@ export class BackgroundManager {
|
||||
private shutdownTriggered = false
|
||||
private config?: BackgroundTaskConfig
|
||||
private tmuxEnabled: boolean
|
||||
private resolvedMultiplexer: ResolvedMultiplexer
|
||||
private onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
private onShutdown?: () => void | Promise<void>
|
||||
|
||||
@@ -161,6 +166,7 @@ export class BackgroundManager {
|
||||
config?: BackgroundTaskConfig,
|
||||
options?: {
|
||||
tmuxConfig?: TmuxConfig
|
||||
resolvedMultiplexer?: ResolvedMultiplexer
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onShutdown?: () => void | Promise<void>
|
||||
enableParentSessionNotifications?: boolean
|
||||
@@ -175,6 +181,10 @@ export class BackgroundManager {
|
||||
this.concurrencyManager = new ConcurrencyManager(config)
|
||||
this.config = config
|
||||
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
||||
this.resolvedMultiplexer =
|
||||
options?.resolvedMultiplexer
|
||||
?? getResolvedMultiplexerRuntime()
|
||||
?? createDisabledMultiplexerRuntime()
|
||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||
this.onShutdown = options?.onShutdown
|
||||
this.rootDescendantCounts = new Map()
|
||||
@@ -455,12 +465,17 @@ export class BackgroundManager {
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!this.onSubagentSessionCreated,
|
||||
tmuxEnabled: this.tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
paneBackend: this.resolvedMultiplexer.paneBackend,
|
||||
multiplexerMode: this.resolvedMultiplexer.mode,
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
|
||||
if (
|
||||
this.onSubagentSessionCreated
|
||||
&& this.tmuxEnabled
|
||||
&& this.resolvedMultiplexer.paneBackend === "tmux"
|
||||
) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await this.onSubagentSessionCreated({
|
||||
sessionID,
|
||||
|
||||
@@ -5,7 +5,11 @@ import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createIn
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import {
|
||||
createDisabledMultiplexerRuntime,
|
||||
getResolvedMultiplexerRuntime,
|
||||
} from "../../shared/tmux"
|
||||
import type { ResolvedMultiplexer } from "../../shared/tmux"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export interface SpawnerContext {
|
||||
@@ -13,6 +17,7 @@ export interface SpawnerContext {
|
||||
directory: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
tmuxEnabled: boolean
|
||||
resolvedMultiplexer?: ResolvedMultiplexer
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onTaskError: (task: BackgroundTask, error: Error) => void
|
||||
}
|
||||
@@ -38,7 +43,19 @@ export async function startTask(
|
||||
ctx: SpawnerContext
|
||||
): Promise<void> {
|
||||
const { task, input } = item
|
||||
const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx
|
||||
const {
|
||||
client,
|
||||
directory,
|
||||
concurrencyManager,
|
||||
tmuxEnabled,
|
||||
resolvedMultiplexer,
|
||||
onSubagentSessionCreated,
|
||||
onTaskError,
|
||||
} = ctx
|
||||
const multiplexerRuntime =
|
||||
resolvedMultiplexer
|
||||
?? getResolvedMultiplexerRuntime()
|
||||
?? createDisabledMultiplexerRuntime()
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
@@ -83,12 +100,17 @@ export async function startTask(
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
paneBackend: multiplexerRuntime.paneBackend,
|
||||
multiplexerMode: multiplexerRuntime.mode,
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
if (
|
||||
onSubagentSessionCreated
|
||||
&& tmuxEnabled
|
||||
&& multiplexerRuntime.paneBackend === "tmux"
|
||||
) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
import {
|
||||
createDisabledMultiplexerRuntime,
|
||||
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 +34,38 @@ export interface TmuxUtilDeps {
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
function isTmuxUtilDeps(value: unknown): value is TmuxUtilDeps {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Partial<TmuxUtilDeps>
|
||||
return (
|
||||
typeof candidate.isInsideTmux === "function"
|
||||
&& typeof candidate.getCurrentPaneId === "function"
|
||||
)
|
||||
}
|
||||
|
||||
function createRuntimeFromLegacyDeps(deps: TmuxUtilDeps): ResolvedMultiplexer {
|
||||
const runtime = createDisabledMultiplexerRuntime()
|
||||
const insideTmux = deps.isInsideTmux()
|
||||
if (!insideTmux) {
|
||||
return runtime
|
||||
}
|
||||
|
||||
return {
|
||||
...runtime,
|
||||
mode: "tmux-only",
|
||||
paneBackend: "tmux",
|
||||
tmux: {
|
||||
...runtime.tmux,
|
||||
reachable: true,
|
||||
insideEnvironment: true,
|
||||
paneId: deps.getCurrentPaneId(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: defaultIsInsideTmux,
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
@@ -67,11 +101,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
|
||||
|
||||
if (isTmuxUtilDeps(runtimeOrDeps)) {
|
||||
this.deps = runtimeOrDeps
|
||||
this.resolvedMultiplexer = createRuntimeFromLegacyDeps(runtimeOrDeps)
|
||||
} else {
|
||||
this.deps = deps
|
||||
this.resolvedMultiplexer = runtimeOrDeps
|
||||
}
|
||||
|
||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||
const fallbackUrl = `http://localhost:${defaultPort}`
|
||||
try {
|
||||
@@ -86,7 +134,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 +145,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 +490,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,
|
||||
|
||||
189
src/hooks/cmux-notification-adapter.test.ts
Normal file
189
src/hooks/cmux-notification-adapter.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
createCmuxNotificationAdapter,
|
||||
type CmuxNotifyCommandResult,
|
||||
} from "./cmux-notification-adapter"
|
||||
import type { ResolvedMultiplexer } from "../shared/tmux"
|
||||
|
||||
function createResolvedMultiplexer(): ResolvedMultiplexer {
|
||||
return {
|
||||
platform: "darwin",
|
||||
mode: "cmux-shim",
|
||||
paneBackend: "tmux",
|
||||
notificationBackend: "cmux",
|
||||
tmux: {
|
||||
path: "/usr/bin/tmux",
|
||||
reachable: true,
|
||||
insideEnvironment: true,
|
||||
paneId: "%1",
|
||||
explicitDisable: false,
|
||||
},
|
||||
cmux: {
|
||||
path: "/usr/local/bin/cmux",
|
||||
reachable: true,
|
||||
notifyCapable: true,
|
||||
socketPath: "/tmp/cmux.sock",
|
||||
endpointType: "unix",
|
||||
workspaceId: "workspace-1",
|
||||
surfaceId: "surface-1",
|
||||
hintStrength: "strong",
|
||||
explicitDisable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createResult(overrides: Partial<CmuxNotifyCommandResult> = {}): CmuxNotifyCommandResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("cmux notification adapter", () => {
|
||||
test("delivers via cmux when notify command succeeds", async () => {
|
||||
let callCount = 0
|
||||
let receivedArgs: string[] = []
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime: createResolvedMultiplexer(),
|
||||
executeCommand: async (input) => {
|
||||
callCount += 1
|
||||
receivedArgs = input.args
|
||||
return createResult()
|
||||
},
|
||||
})
|
||||
|
||||
const delivered = await adapter.send("OpenCode", "Task complete")
|
||||
|
||||
expect(delivered).toBe(true)
|
||||
expect(callCount).toBe(1)
|
||||
expect(receivedArgs).toEqual([
|
||||
"/usr/local/bin/cmux",
|
||||
"notify",
|
||||
"--title",
|
||||
"OpenCode",
|
||||
"--body",
|
||||
"Task complete",
|
||||
"--workspace",
|
||||
"workspace-1",
|
||||
"--surface",
|
||||
"surface-1",
|
||||
])
|
||||
expect(adapter.hasDowngraded()).toBe(false)
|
||||
})
|
||||
|
||||
test("falls back to desktop when cmux notify exits non-zero", async () => {
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime: createResolvedMultiplexer(),
|
||||
executeCommand: async () => createResult({
|
||||
exitCode: 2,
|
||||
stderr: "notify failed",
|
||||
}),
|
||||
})
|
||||
|
||||
const delivered = await adapter.send("OpenCode", "Task complete")
|
||||
|
||||
expect(delivered).toBe(false)
|
||||
expect(adapter.hasDowngraded()).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to desktop when cmux notify times out", async () => {
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime: createResolvedMultiplexer(),
|
||||
executeCommand: async () => createResult({
|
||||
timedOut: true,
|
||||
exitCode: null,
|
||||
}),
|
||||
})
|
||||
|
||||
const delivered = await adapter.send("OpenCode", "Task complete")
|
||||
|
||||
expect(delivered).toBe(false)
|
||||
expect(adapter.hasDowngraded()).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to desktop when output reports connection-refused", async () => {
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime: createResolvedMultiplexer(),
|
||||
executeCommand: async () => createResult({
|
||||
exitCode: 0,
|
||||
stderr: "dial tcp 127.0.0.1:7777: connect: connection refused",
|
||||
}),
|
||||
})
|
||||
|
||||
const delivered = await adapter.send("OpenCode", "Task complete")
|
||||
|
||||
expect(delivered).toBe(false)
|
||||
expect(adapter.hasDowngraded()).toBe(true)
|
||||
})
|
||||
|
||||
test("downgrades permanently after first cmux notify failure", async () => {
|
||||
let callCount = 0
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime: createResolvedMultiplexer(),
|
||||
executeCommand: async () => {
|
||||
callCount += 1
|
||||
return createResult({
|
||||
exitCode: 1,
|
||||
stderr: "notify failed",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const firstDelivered = await adapter.send("OpenCode", "First")
|
||||
const secondDelivered = await adapter.send("OpenCode", "Second")
|
||||
|
||||
expect(firstDelivered).toBe(false)
|
||||
expect(secondDelivered).toBe(false)
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test("returns promptly on timeout when cmux process ignores TERM", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return
|
||||
}
|
||||
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "cmux-notify-timeout-"))
|
||||
const fakeCmuxPath = join(tempDirectory, "cmux")
|
||||
const slowCmuxScript = `#!/bin/sh
|
||||
if [ "$1" = "notify" ]; then
|
||||
trap '' TERM
|
||||
/bin/sleep 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
`
|
||||
|
||||
writeFileSync(fakeCmuxPath, slowCmuxScript)
|
||||
chmodSync(fakeCmuxPath, 0o755)
|
||||
|
||||
const runtime = createResolvedMultiplexer()
|
||||
runtime.cmux.path = fakeCmuxPath
|
||||
|
||||
try {
|
||||
const adapter = createCmuxNotificationAdapter({
|
||||
runtime,
|
||||
environment: {
|
||||
PATH: tempDirectory,
|
||||
},
|
||||
timeoutMs: 40,
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
const delivered = await adapter.send("OpenCode", "Task complete")
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
|
||||
expect(delivered).toBe(false)
|
||||
expect(adapter.hasDowngraded()).toBe(true)
|
||||
expect(elapsedMs).toBeLessThan(500)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
202
src/hooks/cmux-notification-adapter.ts
Normal file
202
src/hooks/cmux-notification-adapter.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { spawn } from "bun"
|
||||
import type { ResolvedMultiplexer } from "../shared/tmux"
|
||||
import { isConnectionRefusedText } from "../tools/interactive-bash/tmux-path-resolver"
|
||||
|
||||
const DEFAULT_NOTIFY_TIMEOUT_MS = 1200
|
||||
|
||||
export interface CmuxNotifyCommandResult {
|
||||
exitCode: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export type CmuxNotifyCommandExecutor = (input: {
|
||||
args: string[]
|
||||
environment: Record<string, string>
|
||||
timeoutMs: number
|
||||
}) => Promise<CmuxNotifyCommandResult>
|
||||
|
||||
export interface CmuxNotificationAdapter {
|
||||
canSendViaCmux: () => boolean
|
||||
hasDowngraded: () => boolean
|
||||
send: (title: string, message: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function toCommandEnvironment(
|
||||
runtime: ResolvedMultiplexer,
|
||||
environment: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...environment,
|
||||
}
|
||||
|
||||
if (runtime.cmux.socketPath) {
|
||||
merged.CMUX_SOCKET_PATH = runtime.cmux.socketPath
|
||||
}
|
||||
if (runtime.cmux.workspaceId) {
|
||||
merged.CMUX_WORKSPACE_ID = runtime.cmux.workspaceId
|
||||
}
|
||||
if (runtime.cmux.surfaceId) {
|
||||
merged.CMUX_SURFACE_ID = runtime.cmux.surfaceId
|
||||
}
|
||||
|
||||
const commandEnvironment: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (typeof value === "string") {
|
||||
commandEnvironment[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return commandEnvironment
|
||||
}
|
||||
|
||||
async function runCmuxNotifyCommand(input: {
|
||||
args: string[]
|
||||
environment: Record<string, string>
|
||||
timeoutMs: number
|
||||
}): Promise<CmuxNotifyCommandResult> {
|
||||
const proc = spawn(input.args, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: input.environment,
|
||||
})
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const timedOut = await Promise.race([
|
||||
proc.exited.then(() => false).catch(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(true)
|
||||
}, input.timeoutMs)
|
||||
}),
|
||||
])
|
||||
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
// Do not await stdout/stderr after timeout: a process that ignores TERM
|
||||
// may keep pipes open and block fallback completion.
|
||||
void proc.exited.catch(() => {})
|
||||
|
||||
return {
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = await proc.exited.catch(() => null)
|
||||
const stdout = await new Response(proc.stdout).text().catch(() => "")
|
||||
const stderr = await new Response(proc.stderr).text().catch(() => "")
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCmuxNotifyArgs(runtime: ResolvedMultiplexer, title: string, message: string): string[] {
|
||||
const cmuxPath = runtime.cmux.path ?? "cmux"
|
||||
const args: string[] = [cmuxPath, "notify", "--title", title, "--body", message]
|
||||
|
||||
if (runtime.cmux.workspaceId) {
|
||||
args.push("--workspace", runtime.cmux.workspaceId)
|
||||
}
|
||||
|
||||
if (runtime.cmux.surfaceId) {
|
||||
args.push("--surface", runtime.cmux.surfaceId)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function shouldDowngrade(result: CmuxNotifyCommandResult): boolean {
|
||||
if (result.timedOut) {
|
||||
return true
|
||||
}
|
||||
|
||||
const combinedOutput = `${result.stderr}\n${result.stdout}`
|
||||
if (isConnectionRefusedText(combinedOutput)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function createCmuxNotificationAdapter(args: {
|
||||
runtime: ResolvedMultiplexer
|
||||
environment?: Record<string, string | undefined>
|
||||
timeoutMs?: number
|
||||
executeCommand?: CmuxNotifyCommandExecutor
|
||||
}): CmuxNotificationAdapter {
|
||||
const {
|
||||
runtime,
|
||||
environment = process.env,
|
||||
timeoutMs = DEFAULT_NOTIFY_TIMEOUT_MS,
|
||||
executeCommand = runCmuxNotifyCommand,
|
||||
} = args
|
||||
|
||||
let downgradedToDesktop = false
|
||||
|
||||
const canSendViaCmux = (): boolean => {
|
||||
if (downgradedToDesktop) return false
|
||||
if (runtime.notificationBackend !== "cmux") return false
|
||||
if (!runtime.cmux.path) return false
|
||||
if (!runtime.cmux.socketPath) return false
|
||||
if (!runtime.cmux.reachable) return false
|
||||
if (!runtime.cmux.notifyCapable) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const hasDowngraded = (): boolean => downgradedToDesktop
|
||||
|
||||
const send = async (title: string, message: string): Promise<boolean> => {
|
||||
if (!canSendViaCmux()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const commandEnvironment = toCommandEnvironment(runtime, environment)
|
||||
const commandResult = await executeCommand({
|
||||
args: buildCmuxNotifyArgs(runtime, title, message),
|
||||
environment: commandEnvironment,
|
||||
timeoutMs,
|
||||
}).catch(() => {
|
||||
downgradedToDesktop = true
|
||||
return null
|
||||
})
|
||||
|
||||
if (!commandResult) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shouldDowngrade(commandResult)) {
|
||||
downgradedToDesktop = true
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
canSendViaCmux,
|
||||
hasDowngraded,
|
||||
send,
|
||||
}
|
||||
}
|
||||
88
src/hooks/session-notification-scheduler.test.ts
Normal file
88
src/hooks/session-notification-scheduler.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, jest, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolvePromise: (value: T | PromiseLike<T>) => void = () => {}
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: resolvePromise,
|
||||
}
|
||||
}
|
||||
|
||||
describe("session-notification-scheduler", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test("does not resend when notification version entry is evicted during delivery", async () => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
const firstSendGate = createDeferred<void>()
|
||||
let firstSendStarted = false
|
||||
const sendCalls: string[] = []
|
||||
|
||||
const scheduler = createIdleNotificationScheduler({
|
||||
ctx: {} as PluginInput,
|
||||
platform: "darwin",
|
||||
config: {
|
||||
playSound: false,
|
||||
soundPath: "",
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
maxTrackedSessions: 1,
|
||||
activityGracePeriodMs: 0,
|
||||
},
|
||||
hasIncompleteTodos: async () => false,
|
||||
send: async (_ctx, _platform, sessionID) => {
|
||||
sendCalls.push(sessionID)
|
||||
|
||||
if (sessionID !== "session-a") {
|
||||
return true
|
||||
}
|
||||
|
||||
firstSendStarted = true
|
||||
await firstSendGate.promise
|
||||
|
||||
return true
|
||||
},
|
||||
playSound: async () => {},
|
||||
})
|
||||
|
||||
scheduler.scheduleIdleNotification("session-a")
|
||||
jest.advanceTimersByTime(10)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(sendCalls).toEqual(["session-a"])
|
||||
|
||||
scheduler.scheduleIdleNotification("session-b")
|
||||
jest.advanceTimersByTime(10)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(sendCalls).toEqual(["session-a", "session-b"])
|
||||
|
||||
if (!firstSendStarted) {
|
||||
throw new Error("Expected the first send call to be in-flight")
|
||||
}
|
||||
|
||||
firstSendGate.resolve()
|
||||
await flushMicrotasks()
|
||||
|
||||
scheduler.scheduleIdleNotification("session-a")
|
||||
jest.advanceTimersByTime(10)
|
||||
await flushMicrotasks()
|
||||
|
||||
const sessionASendCount = sendCalls.filter(id => id === "session-a").length
|
||||
expect(sessionASendCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ export function createIdleNotificationScheduler(options: {
|
||||
platform: Platform
|
||||
config: SessionNotificationConfig
|
||||
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
|
||||
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
|
||||
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<boolean>
|
||||
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
|
||||
}) {
|
||||
const notifiedSessions = new Set<string>()
|
||||
@@ -48,12 +48,6 @@ export function createIdleNotificationScheduler(options: {
|
||||
notificationVersions.delete(id)
|
||||
})
|
||||
}
|
||||
if (executingNotifications.size > maxSessions) {
|
||||
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
|
||||
sessionsToRemove.forEach((id) => {
|
||||
executingNotifications.delete(id)
|
||||
})
|
||||
}
|
||||
if (scheduledAt.size > maxSessions) {
|
||||
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
|
||||
sessionsToRemove.forEach((id) => {
|
||||
@@ -89,6 +83,15 @@ export function createIdleNotificationScheduler(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function hasStaleNotificationVersion(sessionID: string, version: number): boolean {
|
||||
const latestVersion = notificationVersions.get(sessionID)
|
||||
if (latestVersion === undefined) {
|
||||
return !executingNotifications.has(sessionID)
|
||||
}
|
||||
|
||||
return latestVersion !== version
|
||||
}
|
||||
|
||||
async function executeNotification(sessionID: string, version: number): Promise<void> {
|
||||
if (executingNotifications.has(sessionID)) {
|
||||
pendingTimers.delete(sessionID)
|
||||
@@ -96,7 +99,7 @@ export function createIdleNotificationScheduler(options: {
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationVersions.get(sessionID) !== version) {
|
||||
if (hasStaleNotificationVersion(sessionID, version)) {
|
||||
pendingTimers.delete(sessionID)
|
||||
scheduledAt.delete(sessionID)
|
||||
return
|
||||
@@ -119,13 +122,27 @@ export function createIdleNotificationScheduler(options: {
|
||||
try {
|
||||
if (options.config.skipIfIncompleteTodos) {
|
||||
const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID)
|
||||
if (notificationVersions.get(sessionID) !== version) {
|
||||
if (hasStaleNotificationVersion(sessionID, version)) {
|
||||
return
|
||||
}
|
||||
if (hasPendingWork) return
|
||||
}
|
||||
|
||||
if (notificationVersions.get(sessionID) !== version) {
|
||||
if (hasStaleNotificationVersion(sessionID, version)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionActivitySinceIdle.has(sessionID)) {
|
||||
sessionActivitySinceIdle.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const delivered = await options.send(options.ctx, options.platform, sessionID)
|
||||
if (!delivered) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasStaleNotificationVersion(sessionID, version)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,8 +153,6 @@ export function createIdleNotificationScheduler(options: {
|
||||
|
||||
notifiedSessions.add(sessionID)
|
||||
|
||||
await options.send(options.ctx, options.platform, sessionID)
|
||||
|
||||
if (options.config.playSound && options.config.soundPath) {
|
||||
await options.playSound(options.ctx, options.platform, options.config.soundPath)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
||||
import * as utils from "./session-notification-utils"
|
||||
import * as sender from "./session-notification-sender"
|
||||
import type { ResolvedMultiplexer } from "../shared/tmux"
|
||||
import type { CmuxNotificationAdapter } from "./cmux-notification-adapter"
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
@@ -33,6 +35,42 @@ describe("session-notification", () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
function createCmuxRuntime(): ResolvedMultiplexer {
|
||||
return {
|
||||
platform: "darwin",
|
||||
mode: "cmux-shim",
|
||||
paneBackend: "tmux",
|
||||
notificationBackend: "cmux",
|
||||
tmux: {
|
||||
path: "/usr/bin/tmux",
|
||||
reachable: true,
|
||||
insideEnvironment: true,
|
||||
paneId: "%1",
|
||||
explicitDisable: false,
|
||||
},
|
||||
cmux: {
|
||||
path: "/usr/local/bin/cmux",
|
||||
reachable: true,
|
||||
notifyCapable: true,
|
||||
socketPath: "/tmp/cmux.sock",
|
||||
endpointType: "unix",
|
||||
workspaceId: "workspace-1",
|
||||
surfaceId: "surface-1",
|
||||
hintStrength: "strong",
|
||||
explicitDisable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createCmuxAdapter(overrides: Partial<CmuxNotificationAdapter> = {}): CmuxNotificationAdapter {
|
||||
return {
|
||||
canSendViaCmux: () => true,
|
||||
hasDowngraded: () => false,
|
||||
send: async () => false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers()
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
@@ -387,6 +425,233 @@ 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("retries idle notification when cmux fails on unsupported platform", async () => {
|
||||
const sessionID = "cmux-unsupported-retry"
|
||||
let cmuxSendCalls = 0
|
||||
let cmuxNotifyCommandCalls = 0
|
||||
let cmuxAvailable = true
|
||||
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
|
||||
|
||||
try {
|
||||
const cmuxAdapter = createCmuxAdapter({
|
||||
canSendViaCmux: () => cmuxAvailable,
|
||||
hasDowngraded: () => !cmuxAvailable,
|
||||
send: async () => {
|
||||
cmuxSendCalls += 1
|
||||
|
||||
if (!cmuxAvailable) {
|
||||
return false
|
||||
}
|
||||
|
||||
cmuxNotifyCommandCalls += 1
|
||||
cmuxAvailable = false
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createSessionNotification(
|
||||
createMockPluginInput(),
|
||||
{
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
enforceMainSessionFilter: false,
|
||||
},
|
||||
{
|
||||
resolvedMultiplexer: createCmuxRuntime(),
|
||||
cmuxNotificationAdapter: cmuxAdapter,
|
||||
},
|
||||
)
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(cmuxSendCalls).toBe(2)
|
||||
expect(cmuxNotifyCommandCalls).toBe(1)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
} finally {
|
||||
detectPlatformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("skips unsupported idle scheduling when cmux was never available", async () => {
|
||||
const sessionID = "cmux-unsupported-unavailable"
|
||||
let cmuxSendCalls = 0
|
||||
const detectPlatformSpy = spyOn(sender, "detectPlatform").mockReturnValue("unsupported")
|
||||
|
||||
try {
|
||||
const cmuxAdapter = createCmuxAdapter({
|
||||
canSendViaCmux: () => false,
|
||||
hasDowngraded: () => false,
|
||||
send: async () => {
|
||||
cmuxSendCalls += 1
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createSessionNotification(
|
||||
createMockPluginInput(),
|
||||
{
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
enforceMainSessionFilter: false,
|
||||
},
|
||||
{
|
||||
resolvedMultiplexer: createCmuxRuntime(),
|
||||
cmuxNotificationAdapter: cmuxAdapter,
|
||||
},
|
||||
)
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(cmuxSendCalls).toBe(0)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
} finally {
|
||||
detectPlatformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("suppresses duplicate idle notifications while using cmux backend", async () => {
|
||||
const sessionID = "cmux-duplicate"
|
||||
let cmuxSendCalls = 0
|
||||
|
||||
const cmuxAdapter = createCmuxAdapter({
|
||||
send: async () => {
|
||||
cmuxSendCalls += 1
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createSessionNotification(
|
||||
createMockPluginInput(),
|
||||
{
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
enforceMainSessionFilter: false,
|
||||
},
|
||||
{
|
||||
resolvedMultiplexer: createCmuxRuntime(),
|
||||
cmuxNotificationAdapter: cmuxAdapter,
|
||||
},
|
||||
)
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(cmuxSendCalls).toBe(1)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
function createSenderMockCtx() {
|
||||
const notifyCalls: string[] = []
|
||||
const mockCtx = {
|
||||
|
||||
@@ -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,13 +84,25 @@ export function createSessionNotification(
|
||||
typeof hookCtx.client.session.get !== "function"
|
||||
&& typeof hookCtx.client.session.messages !== "function"
|
||||
) {
|
||||
const deliveredViaCmux = await cmuxNotificationAdapter.send(
|
||||
mergedConfig.title,
|
||||
mergedConfig.message,
|
||||
)
|
||||
if (deliveredViaCmux) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (platform === "unsupported") {
|
||||
return false
|
||||
}
|
||||
|
||||
await sessionNotificationSender.sendSessionNotification(
|
||||
hookCtx,
|
||||
platform,
|
||||
mergedConfig.title,
|
||||
mergedConfig.message,
|
||||
)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
const content = await buildReadyNotificationContent(hookCtx, {
|
||||
@@ -76,7 +111,17 @@ export function createSessionNotification(
|
||||
baseMessage: mergedConfig.message,
|
||||
})
|
||||
|
||||
const deliveredViaCmux = await cmuxNotificationAdapter.send(content.title, content.message)
|
||||
if (deliveredViaCmux) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (platform === "unsupported") {
|
||||
return false
|
||||
}
|
||||
|
||||
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
|
||||
return true
|
||||
},
|
||||
playSound: sessionNotificationSender.playSessionNotificationSound,
|
||||
})
|
||||
@@ -134,7 +179,13 @@ export function createSessionNotification(
|
||||
}
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (currentPlatform === "unsupported") return
|
||||
const cannotDeliverOnUnsupportedPlatform =
|
||||
currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()
|
||||
const shouldFastExitUnsupportedEvent =
|
||||
cannotDeliverOnUnsupportedPlatform
|
||||
&& (event.type !== "session.idle" || !cmuxNotificationAdapter.hasDowngraded())
|
||||
|
||||
if (shouldFastExitUnsupportedEvent) return
|
||||
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
@@ -172,12 +223,18 @@ export function createSessionNotification(
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
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 +256,10 @@ export function createSessionNotification(
|
||||
? mergedConfig.permissionMessage
|
||||
: mergedConfig.questionMessage
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
18
src/index.ts
18
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({
|
||||
|
||||
75
src/openclaw/__tests__/index.test.ts
Normal file
75
src/openclaw/__tests__/index.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { OpenClawConfig } from "../types"
|
||||
|
||||
const wakeGatewayMock = mock(async () => ({
|
||||
gateway: "http-gateway",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
}))
|
||||
const wakeCommandGatewayMock = mock(async () => ({
|
||||
gateway: "command-gateway",
|
||||
success: true,
|
||||
}))
|
||||
const interpolateInstructionMock = mock((template: string) => template)
|
||||
const getCurrentTmuxSessionMock = mock(async () => "workspace-main")
|
||||
const captureTmuxPaneMock = mock(async () => null)
|
||||
const startReplyListenerMock = mock(async () => {})
|
||||
const stopReplyListenerMock = mock(() => {})
|
||||
|
||||
mock.module("../dispatcher", () => ({
|
||||
wakeGateway: wakeGatewayMock,
|
||||
wakeCommandGateway: wakeCommandGatewayMock,
|
||||
interpolateInstruction: interpolateInstructionMock,
|
||||
}))
|
||||
|
||||
mock.module("../tmux", () => ({
|
||||
getCurrentTmuxSession: getCurrentTmuxSessionMock,
|
||||
captureTmuxPane: captureTmuxPaneMock,
|
||||
}))
|
||||
|
||||
mock.module("../reply-listener", () => ({
|
||||
startReplyListener: startReplyListenerMock,
|
||||
stopReplyListener: stopReplyListenerMock,
|
||||
}))
|
||||
|
||||
const { wakeOpenClaw } = await import("../index")
|
||||
|
||||
describe("wakeOpenClaw tmux session resolution", () => {
|
||||
beforeEach(() => {
|
||||
wakeGatewayMock.mockClear()
|
||||
wakeCommandGatewayMock.mockClear()
|
||||
interpolateInstructionMock.mockClear()
|
||||
getCurrentTmuxSessionMock.mockClear()
|
||||
captureTmuxPaneMock.mockClear()
|
||||
startReplyListenerMock.mockClear()
|
||||
stopReplyListenerMock.mockClear()
|
||||
})
|
||||
|
||||
test("awaits asynchronous tmux session lookup before dispatch", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
commandGateway: {
|
||||
type: "command",
|
||||
method: "POST",
|
||||
command: "echo {{tmuxSession}}",
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
enabled: true,
|
||||
gateway: "commandGateway",
|
||||
instruction: "tmux session: {{tmuxSession}}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await wakeOpenClaw(config, "session-start", {})
|
||||
|
||||
expect(getCurrentTmuxSessionMock).toHaveBeenCalledTimes(1)
|
||||
expect(interpolateInstructionMock).toHaveBeenCalledTimes(1)
|
||||
expect(interpolateInstructionMock.mock.calls[0]?.[1]?.tmuxSession).toBe("workspace-main")
|
||||
expect(wakeCommandGatewayMock).toHaveBeenCalledTimes(1)
|
||||
expect(wakeCommandGatewayMock.mock.calls[0]?.[2]?.tmuxSession).toBe("workspace-main")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { analyzePaneContent } from "../tmux"
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import {
|
||||
createDisabledMultiplexerRuntime,
|
||||
resetResolvedMultiplexerRuntimeForTesting,
|
||||
setResolvedMultiplexerRuntime,
|
||||
} from "../../shared/tmux"
|
||||
import { analyzePaneContent, getCurrentTmuxSession } from "../tmux"
|
||||
import * as tmuxPathResolver from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
|
||||
describe("openclaw tmux helpers", () => {
|
||||
beforeEach(() => {
|
||||
resetMultiplexerPathCacheForTesting()
|
||||
resetResolvedMultiplexerRuntimeForTesting()
|
||||
})
|
||||
|
||||
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
|
||||
const content = "opencode\nAsk anything...\nRun /help"
|
||||
expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1)
|
||||
@@ -10,4 +22,35 @@ describe("openclaw tmux helpers", () => {
|
||||
test("analyzePaneContent returns zero confidence for empty content", () => {
|
||||
expect(analyzePaneContent(null).confidence).toBe(0)
|
||||
})
|
||||
|
||||
test("getCurrentTmuxSession does not synthesize a session from TMUX_PANE", async () => {
|
||||
const originalTmux = process.env.TMUX
|
||||
const originalTmuxPane = process.env.TMUX_PANE
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue("/usr/bin/tmux")
|
||||
|
||||
try {
|
||||
process.env.TMUX = "/tmp/tmux-501/default,1,0"
|
||||
process.env.TMUX_PANE = "%42"
|
||||
setResolvedMultiplexerRuntime(createDisabledMultiplexerRuntime())
|
||||
|
||||
const sessionName = await getCurrentTmuxSession()
|
||||
|
||||
expect(sessionName).toBeNull()
|
||||
expect(getTmuxPathSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (originalTmux === undefined) {
|
||||
delete process.env.TMUX
|
||||
} else {
|
||||
process.env.TMUX = originalTmux
|
||||
}
|
||||
|
||||
if (originalTmuxPane === undefined) {
|
||||
delete process.env.TMUX_PANE
|
||||
} else {
|
||||
process.env.TMUX_PANE = originalTmuxPane
|
||||
}
|
||||
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,10 @@ export async function wakeOpenClaw(
|
||||
...(replyThread !== undefined && { replyThread }),
|
||||
}
|
||||
|
||||
const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined
|
||||
const tmuxSession =
|
||||
enrichedContext.tmuxSession
|
||||
?? (await getCurrentTmuxSession())
|
||||
?? undefined
|
||||
|
||||
let tmuxTail = enrichedContext.tmuxTail
|
||||
if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
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
|
||||
// Reference tmux.js gets session name via `tmux display-message -p '#S'`
|
||||
export async function getCurrentTmuxSession(): Promise<string | null> {
|
||||
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
|
||||
|
||||
return getTmuxSessionName()
|
||||
}
|
||||
|
||||
export async function getTmuxSessionName(): Promise<string | null> {
|
||||
try {
|
||||
const proc = spawn(["tmux", "display-message", "-p", "#S"], {
|
||||
const tmuxPath = await getTmuxPath()
|
||||
if (!tmuxPath) return null
|
||||
|
||||
const proc = spawn([tmuxPath, "display-message", "-p", "#S"], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
@@ -27,8 +44,11 @@ export async function getTmuxSessionName(): Promise<string | null> {
|
||||
|
||||
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
|
||||
try {
|
||||
const tmuxPath = await getTmuxPath()
|
||||
if (!tmuxPath) return null
|
||||
|
||||
const proc = spawn(
|
||||
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
|
||||
[tmuxPath, "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
@@ -46,7 +66,10 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
|
||||
|
||||
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
|
||||
try {
|
||||
const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], {
|
||||
const tmuxPath = await getTmuxPath()
|
||||
if (!tmuxPath) return false
|
||||
|
||||
const literalProc = spawn([tmuxPath, "send-keys", "-t", paneId, "-l", "--", text], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
@@ -55,7 +78,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 +90,16 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
|
||||
}
|
||||
|
||||
export async function isTmuxAvailable(): Promise<boolean> {
|
||||
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
|
||||
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn(["tmux", "-V"], {
|
||||
const tmuxPath = await getTmuxPath()
|
||||
if (!tmuxPath) return false
|
||||
|
||||
const proc = spawn([tmuxPath, "-V"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
@@ -341,6 +341,7 @@ export function createEventHandler(args: {
|
||||
|
||||
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
||||
|
||||
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
|
||||
await managers.tmuxSessionManager.onSessionCreated(
|
||||
event as {
|
||||
type: string;
|
||||
@@ -350,6 +351,7 @@ export function createEventHandler(args: {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
@@ -376,11 +378,13 @@ export function createEventHandler(args: {
|
||||
deleteSessionTools(sessionInfo.id);
|
||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
|
||||
await managers.tmuxSessionManager.onSessionDeleted({
|
||||
sessionID: sessionInfo.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||
import type { ModelCacheState } from "../../plugin-state"
|
||||
import type { PluginContext } from "../types"
|
||||
import type { ResolvedMultiplexer } from "../../shared/tmux"
|
||||
|
||||
import {
|
||||
createContextWindowMonitorHook,
|
||||
@@ -70,8 +71,16 @@ export function createSessionHooks(args: {
|
||||
modelCacheState: ModelCacheState
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
resolvedMultiplexer: ResolvedMultiplexer
|
||||
}): SessionHooks {
|
||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
||||
const {
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
resolvedMultiplexer,
|
||||
} = args
|
||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
|
||||
@@ -99,7 +108,10 @@ export function createSessionHooks(args: {
|
||||
if (externalNotifier.detected && !forceEnable) {
|
||||
log(getNotificationConflictWarning(externalNotifier.pluginName!))
|
||||
} else {
|
||||
sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx))
|
||||
sessionNotification = safeHook("session-notification", () =>
|
||||
createSessionNotification(ctx, {}, {
|
||||
resolvedMultiplexer,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
discoverCommandsSync,
|
||||
interactive_bash,
|
||||
createInteractiveBashTool,
|
||||
createTaskCreateTool,
|
||||
createTaskGetTool,
|
||||
createTaskList,
|
||||
@@ -100,7 +100,7 @@ function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
|
||||
export function createToolRegistry(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
||||
managers: Pick<Managers, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
||||
skillContext: SkillContext
|
||||
availableCategories: AvailableCategory[]
|
||||
}): ToolRegistryResult {
|
||||
@@ -134,6 +134,10 @@ export function createToolRegistry(args: {
|
||||
availableSkills: skillContext.availableSkills,
|
||||
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
|
||||
onSyncSessionCreated: async (event) => {
|
||||
if (managers.resolvedMultiplexer.paneBackend !== "tmux") {
|
||||
return
|
||||
}
|
||||
|
||||
log("[index] onSyncSessionCreated callback", {
|
||||
sessionID: event.sessionID,
|
||||
parentID: event.parentID,
|
||||
@@ -202,7 +206,7 @@ export function createToolRegistry(args: {
|
||||
task: delegateTask,
|
||||
skill_mcp: skillMcpTool,
|
||||
skill: skillTool,
|
||||
interactive_bash,
|
||||
interactive_bash: createInteractiveBashTool(managers.resolvedMultiplexer),
|
||||
...taskToolsRecord,
|
||||
...hashlineToolsRecord,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import type { ResolvedMultiplexer } from "./multiplexer-runtime"
|
||||
import { getResolvedMultiplexerRuntime } from "./multiplexer-runtime"
|
||||
|
||||
export type SplitDirection = "-h" | "-v"
|
||||
|
||||
function resolveRuntime(runtime: ResolvedMultiplexer | undefined): ResolvedMultiplexer | null {
|
||||
return runtime ?? getResolvedMultiplexerRuntime()
|
||||
}
|
||||
|
||||
export function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
|
||||
return Boolean(environment.TMUX)
|
||||
}
|
||||
|
||||
export function isInsideTmux(): boolean {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
297
src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts
Normal file
297
src/shared/tmux/tmux-utils/multiplexer-runtime.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import {
|
||||
createDisabledMultiplexerRuntime,
|
||||
resolveMultiplexerFromProbes,
|
||||
resolveMultiplexerRuntime,
|
||||
type ResolvedMultiplexer,
|
||||
} from "./multiplexer-runtime"
|
||||
import {
|
||||
probeCmuxReachability,
|
||||
resetMultiplexerPathCacheForTesting,
|
||||
type CmuxRuntimeProbe,
|
||||
type TmuxRuntimeProbe,
|
||||
} from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
|
||||
function createTmuxProbe(overrides: Partial<TmuxRuntimeProbe> = {}): TmuxRuntimeProbe {
|
||||
return {
|
||||
path: "/usr/bin/tmux",
|
||||
reachable: true,
|
||||
paneControlReachable: true,
|
||||
explicitDisable: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createCmuxProbe(overrides: Partial<CmuxRuntimeProbe> = {}): CmuxRuntimeProbe {
|
||||
return {
|
||||
path: "/usr/local/bin/cmux",
|
||||
socketPath: "/tmp/cmux.sock",
|
||||
endpointType: "unix",
|
||||
workspaceId: "workspace-1",
|
||||
surfaceId: "surface-1",
|
||||
hintStrength: "strong",
|
||||
reachable: true,
|
||||
explicitDisable: false,
|
||||
notifyCapable: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntime(args: {
|
||||
environment?: Record<string, string | undefined>
|
||||
platform?: NodeJS.Platform
|
||||
tmuxEnabled?: boolean
|
||||
cmuxEnabled?: boolean
|
||||
tmuxProbe?: Partial<TmuxRuntimeProbe>
|
||||
cmuxProbe?: Partial<CmuxRuntimeProbe>
|
||||
}): ResolvedMultiplexer {
|
||||
return resolveMultiplexerFromProbes({
|
||||
platform: args.platform ?? "darwin",
|
||||
environment: {
|
||||
TMUX: "/tmp/tmux-501/default,999,0",
|
||||
TMUX_PANE: "%1",
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
||||
CMUX_WORKSPACE_ID: "workspace-1",
|
||||
CMUX_SURFACE_ID: "surface-1",
|
||||
TERM_PROGRAM: "ghostty",
|
||||
...args.environment,
|
||||
},
|
||||
tmuxEnabled: args.tmuxEnabled ?? true,
|
||||
cmuxEnabled: args.cmuxEnabled ?? true,
|
||||
tmuxProbe: createTmuxProbe(args.tmuxProbe),
|
||||
cmuxProbe: createCmuxProbe(args.cmuxProbe),
|
||||
})
|
||||
}
|
||||
|
||||
describe("multiplexer runtime resolution", () => {
|
||||
test("resolves cmux-shim when both runtimes are live", () => {
|
||||
const runtime = resolveRuntime({})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-shim")
|
||||
expect(runtime.paneBackend).toBe("tmux")
|
||||
expect(runtime.notificationBackend).toBe("cmux")
|
||||
})
|
||||
|
||||
test("resolves tmux-only when cmux is unreachable", () => {
|
||||
const runtime = resolveRuntime({
|
||||
cmuxProbe: {
|
||||
reachable: false,
|
||||
failureKind: "missing-socket",
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("tmux-only")
|
||||
expect(runtime.paneBackend).toBe("tmux")
|
||||
expect(runtime.notificationBackend).toBe("desktop")
|
||||
})
|
||||
|
||||
test("resolves cmux-notify-only when tmux pane control is unavailable", () => {
|
||||
const runtime = resolveRuntime({
|
||||
tmuxProbe: {
|
||||
paneControlReachable: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-notify-only")
|
||||
expect(runtime.paneBackend).toBe("none")
|
||||
expect(runtime.notificationBackend).toBe("cmux")
|
||||
})
|
||||
|
||||
test("resolves none when both runtimes are unavailable", () => {
|
||||
const runtime = resolveRuntime({
|
||||
environment: {
|
||||
TMUX: undefined,
|
||||
TMUX_PANE: undefined,
|
||||
CMUX_SOCKET_PATH: undefined,
|
||||
},
|
||||
tmuxProbe: {
|
||||
reachable: false,
|
||||
paneControlReachable: false,
|
||||
path: null,
|
||||
},
|
||||
cmuxProbe: {
|
||||
reachable: false,
|
||||
path: null,
|
||||
socketPath: undefined,
|
||||
endpointType: "missing",
|
||||
hintStrength: "none",
|
||||
notifyCapable: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("none")
|
||||
expect(runtime.paneBackend).toBe("none")
|
||||
expect(runtime.notificationBackend).toBe("desktop")
|
||||
})
|
||||
|
||||
test("keeps cmux-shim for nested tmux-inside-cmux hints", () => {
|
||||
const runtime = resolveRuntime({
|
||||
environment: {
|
||||
TMUX: "/tmp/tmux-501/default,1001,0",
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux-nested.sock",
|
||||
CMUX_WORKSPACE_ID: "workspace-nested",
|
||||
CMUX_SURFACE_ID: "surface-nested",
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-shim")
|
||||
expect(runtime.cmux.hintStrength).toBe("strong")
|
||||
})
|
||||
|
||||
test("downgrades stale cmux socket env to tmux-only", () => {
|
||||
const runtime = resolveRuntime({
|
||||
cmuxProbe: {
|
||||
reachable: false,
|
||||
failureKind: "connection-refused",
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("tmux-only")
|
||||
expect(runtime.notificationBackend).toBe("desktop")
|
||||
})
|
||||
|
||||
test("respects explicit tmux disable and falls to cmux-notify-only", () => {
|
||||
const runtime = resolveRuntime({
|
||||
tmuxEnabled: false,
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-notify-only")
|
||||
expect(runtime.paneBackend).toBe("none")
|
||||
})
|
||||
|
||||
test("keeps desktop notifications when cmux is reachable without notify capability", () => {
|
||||
const runtime = resolveRuntime({
|
||||
cmuxProbe: {
|
||||
notifyCapable: false,
|
||||
notifyFailureKind: "exit-non-zero",
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-shim")
|
||||
expect(runtime.notificationBackend).toBe("desktop")
|
||||
})
|
||||
|
||||
test("treats relay endpoint addresses as valid cmux socket targets", async () => {
|
||||
const derivedProbe = await probeCmuxReachability({
|
||||
environment: {
|
||||
CMUX_SOCKET_PATH: "127.0.0.1:7777",
|
||||
OH_MY_OPENCODE_DISABLE_CMUX: "1",
|
||||
},
|
||||
})
|
||||
|
||||
const runtime = resolveRuntime({
|
||||
environment: {
|
||||
TMUX: undefined,
|
||||
TMUX_PANE: undefined,
|
||||
CMUX_SOCKET_PATH: "127.0.0.1:7777",
|
||||
},
|
||||
cmuxProbe: {
|
||||
endpointType: derivedProbe.endpointType,
|
||||
socketPath: derivedProbe.socketPath,
|
||||
},
|
||||
})
|
||||
|
||||
expect(derivedProbe.endpointType).toBe("relay")
|
||||
expect(runtime.mode).toBe("cmux-notify-only")
|
||||
expect(runtime.cmux.endpointType).toBe("relay")
|
||||
})
|
||||
|
||||
test("keeps weak ghostty hint as non-authoritative on non-mac platforms", async () => {
|
||||
const derivedProbe = await probeCmuxReachability({
|
||||
environment: {
|
||||
TERM_PROGRAM: "ghostty",
|
||||
CMUX_SOCKET_PATH: undefined,
|
||||
OH_MY_OPENCODE_DISABLE_CMUX: "1",
|
||||
},
|
||||
})
|
||||
|
||||
const runtime = resolveRuntime({
|
||||
platform: "linux",
|
||||
environment: {
|
||||
TMUX: undefined,
|
||||
TMUX_PANE: undefined,
|
||||
TERM_PROGRAM: "ghostty",
|
||||
CMUX_SOCKET_PATH: undefined,
|
||||
},
|
||||
tmuxProbe: {
|
||||
reachable: false,
|
||||
paneControlReachable: false,
|
||||
path: null,
|
||||
},
|
||||
cmuxProbe: {
|
||||
reachable: false,
|
||||
path: "/usr/local/bin/cmux",
|
||||
socketPath: derivedProbe.socketPath,
|
||||
endpointType: derivedProbe.endpointType,
|
||||
hintStrength: derivedProbe.hintStrength,
|
||||
notifyCapable: false,
|
||||
failureKind: "missing-socket",
|
||||
},
|
||||
})
|
||||
|
||||
expect(derivedProbe.hintStrength).toBe("weak")
|
||||
expect(runtime.mode).toBe("none")
|
||||
expect(runtime.cmux.hintStrength).toBe("weak")
|
||||
expect(runtime.platform).toBe("linux")
|
||||
})
|
||||
|
||||
test("createDisabledMultiplexerRuntime returns safe defaults", () => {
|
||||
const runtime = createDisabledMultiplexerRuntime("darwin")
|
||||
|
||||
expect(runtime.mode).toBe("none")
|
||||
expect(runtime.paneBackend).toBe("none")
|
||||
expect(runtime.notificationBackend).toBe("desktop")
|
||||
expect(runtime.cmux.endpointType).toBe("missing")
|
||||
})
|
||||
|
||||
test("downgrades stale tmux environment even when tmux binary exists", () => {
|
||||
const runtime = resolveRuntime({
|
||||
tmuxProbe: {
|
||||
reachable: true,
|
||||
paneControlReachable: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.mode).toBe("cmux-notify-only")
|
||||
expect(runtime.paneBackend).toBe("none")
|
||||
expect(runtime.tmux.reachable).toBe(false)
|
||||
expect(runtime.tmux.insideEnvironment).toBe(true)
|
||||
})
|
||||
|
||||
test("skips tmux and cmux path probing when both backends are disabled", async () => {
|
||||
resetMultiplexerPathCacheForTesting()
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await resolveMultiplexerRuntime({
|
||||
environment: {},
|
||||
tmuxEnabled: false,
|
||||
cmuxEnabled: false,
|
||||
})
|
||||
|
||||
expect(whichSpy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("only probes cmux path when tmux backend is disabled", async () => {
|
||||
resetMultiplexerPathCacheForTesting()
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await resolveMultiplexerRuntime({
|
||||
environment: {
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
||||
},
|
||||
tmuxEnabled: false,
|
||||
cmuxEnabled: true,
|
||||
})
|
||||
|
||||
expect(whichSpy.mock.calls.length).toBeGreaterThan(0)
|
||||
expect(whichSpy.mock.calls.every((call) => call[0] === "cmux")).toBe(true)
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
236
src/shared/tmux/tmux-utils/multiplexer-runtime.ts
Normal file
236
src/shared/tmux/tmux-utils/multiplexer-runtime.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
probeCmuxRuntime,
|
||||
probeTmuxRuntime,
|
||||
type CmuxRuntimeProbe,
|
||||
type CmuxEndpointType,
|
||||
type CmuxHintStrength,
|
||||
type TmuxRuntimeProbe,
|
||||
} from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
|
||||
export type MultiplexerMode = "cmux-shim" | "tmux-only" | "cmux-notify-only" | "none"
|
||||
export type PaneBackend = "tmux" | "none"
|
||||
export type NotificationBackend = "cmux" | "desktop"
|
||||
|
||||
export interface ResolvedTmuxRuntime {
|
||||
path: string | null
|
||||
reachable: boolean
|
||||
insideEnvironment: boolean
|
||||
paneId: string | undefined
|
||||
explicitDisable: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedCmuxRuntime {
|
||||
path: string | null
|
||||
reachable: boolean
|
||||
notifyCapable: boolean
|
||||
socketPath: string | undefined
|
||||
endpointType: CmuxEndpointType
|
||||
workspaceId: string | undefined
|
||||
surfaceId: string | undefined
|
||||
hintStrength: CmuxHintStrength
|
||||
explicitDisable: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedMultiplexer {
|
||||
platform: NodeJS.Platform
|
||||
mode: MultiplexerMode
|
||||
paneBackend: PaneBackend
|
||||
notificationBackend: NotificationBackend
|
||||
tmux: ResolvedTmuxRuntime
|
||||
cmux: ResolvedCmuxRuntime
|
||||
}
|
||||
|
||||
export interface ResolveMultiplexerRuntimeOptions {
|
||||
environment?: Record<string, string | undefined>
|
||||
platform?: NodeJS.Platform
|
||||
tmuxEnabled?: boolean
|
||||
cmuxEnabled?: boolean
|
||||
tmuxProbe?: TmuxRuntimeProbe
|
||||
cmuxProbe?: CmuxRuntimeProbe
|
||||
}
|
||||
|
||||
function normalizeEnvValue(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
|
||||
return Boolean(normalizeEnvValue(environment.TMUX))
|
||||
}
|
||||
|
||||
function resolveMode(input: {
|
||||
hasLiveTmuxPaneControl: boolean
|
||||
hasLiveCmuxRuntime: boolean
|
||||
}): MultiplexerMode {
|
||||
if (input.hasLiveCmuxRuntime && input.hasLiveTmuxPaneControl) {
|
||||
return "cmux-shim"
|
||||
}
|
||||
|
||||
if (input.hasLiveTmuxPaneControl) {
|
||||
return "tmux-only"
|
||||
}
|
||||
|
||||
if (input.hasLiveCmuxRuntime) {
|
||||
return "cmux-notify-only"
|
||||
}
|
||||
|
||||
return "none"
|
||||
}
|
||||
|
||||
function createDisabledTmuxProbe(): TmuxRuntimeProbe {
|
||||
return {
|
||||
path: null,
|
||||
reachable: false,
|
||||
paneControlReachable: false,
|
||||
explicitDisable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function createDisabledCmuxProbe(): CmuxRuntimeProbe {
|
||||
return {
|
||||
path: null,
|
||||
socketPath: undefined,
|
||||
endpointType: "missing",
|
||||
workspaceId: undefined,
|
||||
surfaceId: undefined,
|
||||
hintStrength: "none",
|
||||
reachable: false,
|
||||
explicitDisable: false,
|
||||
notifyCapable: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDisabledMultiplexerRuntime(platform: NodeJS.Platform = process.platform): ResolvedMultiplexer {
|
||||
return {
|
||||
platform,
|
||||
mode: "none",
|
||||
paneBackend: "none",
|
||||
notificationBackend: "desktop",
|
||||
tmux: {
|
||||
path: null,
|
||||
reachable: false,
|
||||
insideEnvironment: false,
|
||||
paneId: undefined,
|
||||
explicitDisable: false,
|
||||
},
|
||||
cmux: {
|
||||
path: null,
|
||||
reachable: false,
|
||||
notifyCapable: false,
|
||||
socketPath: undefined,
|
||||
endpointType: "missing",
|
||||
workspaceId: undefined,
|
||||
surfaceId: undefined,
|
||||
hintStrength: "none",
|
||||
explicitDisable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMultiplexerFromProbes(args: {
|
||||
platform: NodeJS.Platform
|
||||
environment: Record<string, string | undefined>
|
||||
tmuxEnabled: boolean
|
||||
cmuxEnabled: boolean
|
||||
tmuxProbe: TmuxRuntimeProbe
|
||||
cmuxProbe: CmuxRuntimeProbe
|
||||
}): ResolvedMultiplexer {
|
||||
const insideTmux = isInsideTmuxEnvironment(args.environment)
|
||||
const paneId = normalizeEnvValue(args.environment.TMUX_PANE)
|
||||
|
||||
const hasLiveTmuxPaneControl =
|
||||
args.tmuxEnabled
|
||||
&& !args.tmuxProbe.explicitDisable
|
||||
&& args.tmuxProbe.paneControlReachable
|
||||
&& insideTmux
|
||||
|
||||
const hasLiveCmuxRuntime =
|
||||
args.cmuxEnabled
|
||||
&& !args.cmuxProbe.explicitDisable
|
||||
&& args.cmuxProbe.reachable
|
||||
|
||||
const mode = resolveMode({
|
||||
hasLiveTmuxPaneControl,
|
||||
hasLiveCmuxRuntime,
|
||||
})
|
||||
|
||||
const paneBackend: PaneBackend = hasLiveTmuxPaneControl ? "tmux" : "none"
|
||||
const notificationBackend: NotificationBackend =
|
||||
hasLiveCmuxRuntime && args.cmuxProbe.notifyCapable
|
||||
? "cmux"
|
||||
: "desktop"
|
||||
|
||||
return {
|
||||
platform: args.platform,
|
||||
mode,
|
||||
paneBackend,
|
||||
notificationBackend,
|
||||
tmux: {
|
||||
path: args.tmuxProbe.path,
|
||||
reachable: hasLiveTmuxPaneControl,
|
||||
insideEnvironment: insideTmux,
|
||||
paneId,
|
||||
explicitDisable: args.tmuxProbe.explicitDisable,
|
||||
},
|
||||
cmux: {
|
||||
path: args.cmuxProbe.path,
|
||||
reachable: hasLiveCmuxRuntime,
|
||||
notifyCapable: args.cmuxProbe.notifyCapable,
|
||||
socketPath: args.cmuxProbe.socketPath,
|
||||
endpointType: args.cmuxProbe.endpointType,
|
||||
workspaceId: args.cmuxProbe.workspaceId,
|
||||
surfaceId: args.cmuxProbe.surfaceId,
|
||||
hintStrength: args.cmuxProbe.hintStrength,
|
||||
explicitDisable: args.cmuxProbe.explicitDisable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedMultiplexerRuntime: ResolvedMultiplexer | null = null
|
||||
|
||||
export async function resolveMultiplexerRuntime(
|
||||
options: ResolveMultiplexerRuntimeOptions = {},
|
||||
): Promise<ResolvedMultiplexer> {
|
||||
const environment = options.environment ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
const tmuxEnabled = options.tmuxEnabled ?? true
|
||||
const cmuxEnabled = options.cmuxEnabled ?? true
|
||||
|
||||
const tmuxProbePromise = options.tmuxProbe
|
||||
? Promise.resolve(options.tmuxProbe)
|
||||
: tmuxEnabled
|
||||
? probeTmuxRuntime({ environment })
|
||||
: Promise.resolve(createDisabledTmuxProbe())
|
||||
|
||||
const cmuxProbePromise = options.cmuxProbe
|
||||
? Promise.resolve(options.cmuxProbe)
|
||||
: cmuxEnabled
|
||||
? probeCmuxRuntime({ environment })
|
||||
: Promise.resolve(createDisabledCmuxProbe())
|
||||
|
||||
const [tmuxProbe, cmuxProbe] = await Promise.all([tmuxProbePromise, cmuxProbePromise])
|
||||
|
||||
const resolved = resolveMultiplexerFromProbes({
|
||||
platform,
|
||||
environment,
|
||||
tmuxEnabled,
|
||||
cmuxEnabled,
|
||||
tmuxProbe,
|
||||
cmuxProbe,
|
||||
})
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function setResolvedMultiplexerRuntime(runtime: ResolvedMultiplexer): void {
|
||||
resolvedMultiplexerRuntime = runtime
|
||||
}
|
||||
|
||||
export function getResolvedMultiplexerRuntime(): ResolvedMultiplexer | null {
|
||||
return resolvedMultiplexerRuntime
|
||||
}
|
||||
|
||||
export function resetResolvedMultiplexerRuntimeForTesting(): void {
|
||||
resolvedMultiplexerRuntime = null
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
166
src/tools/interactive-bash/tmux-path-resolver.test.ts
Normal file
166
src/tools/interactive-bash/tmux-path-resolver.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
classifyCmuxEndpoint,
|
||||
isConnectionRefusedText,
|
||||
probeCmuxNotificationCapability,
|
||||
probeCmuxReachability,
|
||||
probeTmuxRuntime,
|
||||
resetMultiplexerPathCacheForTesting,
|
||||
supportsCmuxNotifyFlagModel,
|
||||
} from "./tmux-path-resolver"
|
||||
|
||||
describe("tmux-path-resolver probe environment", () => {
|
||||
beforeEach(() => {
|
||||
resetMultiplexerPathCacheForTesting()
|
||||
})
|
||||
|
||||
test("probeTmuxRuntime resolves tmux using provided PATH", async () => {
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await probeTmuxRuntime({
|
||||
environment: {
|
||||
PATH: "/tmp/custom-tmux-bin",
|
||||
TMUX: "/tmp/tmux-501/default,1,0",
|
||||
TMUX_PANE: "%1",
|
||||
},
|
||||
})
|
||||
|
||||
expect(whichSpy).toHaveBeenCalledTimes(1)
|
||||
expect(whichSpy.mock.calls[0]).toEqual([
|
||||
"tmux",
|
||||
{ PATH: "/tmp/custom-tmux-bin" },
|
||||
])
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("probeCmuxReachability resolves cmux using provided PATH", async () => {
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await probeCmuxReachability({
|
||||
environment: {
|
||||
PATH: "/tmp/custom-cmux-bin",
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
||||
},
|
||||
})
|
||||
|
||||
expect(whichSpy).toHaveBeenCalledTimes(1)
|
||||
expect(whichSpy.mock.calls[0]).toEqual([
|
||||
"cmux",
|
||||
{ PATH: "/tmp/custom-cmux-bin" },
|
||||
])
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("probeTmuxRuntime honors explicit PATH removal in custom environment", async () => {
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await probeTmuxRuntime({
|
||||
environment: {
|
||||
PATH: undefined,
|
||||
TMUX: "/tmp/tmux-501/default,1,0",
|
||||
TMUX_PANE: "%1",
|
||||
},
|
||||
})
|
||||
|
||||
expect(whichSpy).toHaveBeenCalledTimes(1)
|
||||
expect(whichSpy.mock.calls[0]).toEqual([
|
||||
"tmux",
|
||||
{ PATH: "" },
|
||||
])
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return
|
||||
}
|
||||
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-"))
|
||||
const fakeCmuxPath = join(tempDirectory, "cmux")
|
||||
const slowCmuxScript = `#!/bin/sh
|
||||
if [ "$1" = "notify" ]; then
|
||||
trap '' TERM
|
||||
/bin/sleep 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
`
|
||||
|
||||
writeFileSync(fakeCmuxPath, slowCmuxScript)
|
||||
chmodSync(fakeCmuxPath, 0o755)
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const probe = await probeCmuxNotificationCapability({
|
||||
cmuxPath: fakeCmuxPath,
|
||||
environment: {
|
||||
PATH: tempDirectory,
|
||||
},
|
||||
timeoutMs: 40,
|
||||
})
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
|
||||
expect(probe.failureKind).toBe("timeout")
|
||||
expect(elapsedMs).toBeLessThan(500)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("tmux-path-resolver cmux endpoint helpers", () => {
|
||||
test("classifies relay host:port endpoint as relay", () => {
|
||||
expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay")
|
||||
})
|
||||
|
||||
test("classifies unix socket path as unix", () => {
|
||||
expect(classifyCmuxEndpoint("/tmp/cmux.sock")).toBe("unix")
|
||||
})
|
||||
|
||||
test("classifies empty endpoint as missing", () => {
|
||||
expect(classifyCmuxEndpoint(" ")).toBe("missing")
|
||||
})
|
||||
|
||||
test("detects connection refused text", () => {
|
||||
expect(isConnectionRefusedText("connect: connection refused")).toBe(true)
|
||||
expect(isConnectionRefusedText("ECONNREFUSED while connecting")).toBe(true)
|
||||
expect(isConnectionRefusedText("permission denied")).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts cmux notify help output with title/body model", () => {
|
||||
const help = `
|
||||
cmux notify
|
||||
|
||||
Flags:
|
||||
--title <text>
|
||||
--body <text>
|
||||
`
|
||||
|
||||
expect(supportsCmuxNotifyFlagModel(help)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects legacy message-based notify flag model", () => {
|
||||
const legacyHelp = `
|
||||
cmux notify
|
||||
|
||||
Flags:
|
||||
--title <text>
|
||||
--message <text>
|
||||
`
|
||||
|
||||
expect(supportsCmuxNotifyFlagModel(legacyHelp)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,71 +1,641 @@
|
||||
import { spawn } from "bun"
|
||||
|
||||
const DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS = 400
|
||||
const DEFAULT_CMUX_PING_TIMEOUT_MS = 250
|
||||
const DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS = 300
|
||||
const DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS = 250
|
||||
|
||||
const CMUX_RELAY_ENDPOINT_PATTERN = /^[^\s/:]+:\d+$/
|
||||
|
||||
const TMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_TMUX"
|
||||
const CMUX_DISABLE_ENV_KEY = "OH_MY_OPENCODE_DISABLE_CMUX"
|
||||
|
||||
let tmuxPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
let tmuxPathInitialized = false
|
||||
let tmuxPathInitPromise: Promise<string | null> | null = null
|
||||
|
||||
async function findTmuxPath(): Promise<string | null> {
|
||||
const isWindows = process.platform === "win32"
|
||||
const cmd = isWindows ? "where" : "which"
|
||||
let cmuxPath: string | null = null
|
||||
let cmuxPathInitialized = false
|
||||
let cmuxPathInitPromise: Promise<string | null> | null = null
|
||||
|
||||
export type CmuxEndpointType = "missing" | "unix" | "relay"
|
||||
export type CmuxHintStrength = "none" | "weak" | "strong"
|
||||
|
||||
export type CmuxProbeFailureKind =
|
||||
| "explicit-disable"
|
||||
| "missing-binary"
|
||||
| "missing-socket"
|
||||
| "timeout"
|
||||
| "connection-refused"
|
||||
| "exit-non-zero"
|
||||
|
||||
export type CmuxNotificationCapabilityFailureKind =
|
||||
| "explicit-disable"
|
||||
| "missing-binary"
|
||||
| "timeout"
|
||||
| "unsupported-contract"
|
||||
| "exit-non-zero"
|
||||
|
||||
interface ProbeCommandResult {
|
||||
exitCode: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export interface ProbeOptions {
|
||||
environment?: Record<string, string | undefined>
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface TmuxRuntimeProbe {
|
||||
path: string | null
|
||||
reachable: boolean
|
||||
paneControlReachable: boolean
|
||||
explicitDisable: boolean
|
||||
}
|
||||
|
||||
export interface CmuxReachabilityProbe {
|
||||
path: string | null
|
||||
socketPath: string | undefined
|
||||
endpointType: CmuxEndpointType
|
||||
workspaceId: string | undefined
|
||||
surfaceId: string | undefined
|
||||
hintStrength: CmuxHintStrength
|
||||
reachable: boolean
|
||||
explicitDisable: boolean
|
||||
failureKind?: CmuxProbeFailureKind
|
||||
}
|
||||
|
||||
export interface CmuxNotificationCapabilityProbe {
|
||||
capable: boolean
|
||||
explicitDisable: boolean
|
||||
failureKind?: CmuxNotificationCapabilityFailureKind
|
||||
}
|
||||
|
||||
export interface CmuxRuntimeProbe extends CmuxReachabilityProbe {
|
||||
notifyCapable: boolean
|
||||
notifyFailureKind?: CmuxNotificationCapabilityFailureKind
|
||||
}
|
||||
|
||||
function normalizeEnvValue(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
const normalized = normalizeEnvValue(value)
|
||||
if (!normalized) return false
|
||||
|
||||
const lower = normalized.toLowerCase()
|
||||
return lower === "1" || lower === "true" || lower === "yes" || lower === "on"
|
||||
}
|
||||
|
||||
function toProbeEnvironment(
|
||||
environment: Record<string, string | undefined> | undefined,
|
||||
): Record<string, string> {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...(environment ?? {}),
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (typeof value === "string") {
|
||||
normalized[key] = value
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function classifyCmuxEndpoint(endpoint: string | undefined): CmuxEndpointType {
|
||||
const normalized = normalizeEnvValue(endpoint)
|
||||
if (!normalized) return "missing"
|
||||
|
||||
if (CMUX_RELAY_ENDPOINT_PATTERN.test(normalized)) {
|
||||
return "relay"
|
||||
}
|
||||
|
||||
return "unix"
|
||||
}
|
||||
|
||||
function resolveCmuxHintStrength(environment: Record<string, string | undefined>): CmuxHintStrength {
|
||||
const workspaceId = normalizeEnvValue(environment.CMUX_WORKSPACE_ID)
|
||||
const surfaceId = normalizeEnvValue(environment.CMUX_SURFACE_ID)
|
||||
if (workspaceId && surfaceId) {
|
||||
return "strong"
|
||||
}
|
||||
|
||||
const termProgram = normalizeEnvValue(environment.TERM_PROGRAM)
|
||||
if (termProgram?.toLowerCase() === "ghostty") {
|
||||
return "weak"
|
||||
}
|
||||
|
||||
return "none"
|
||||
}
|
||||
|
||||
export function isConnectionRefusedText(value: string): boolean {
|
||||
const normalized = value.toLowerCase()
|
||||
return normalized.includes("connection refused") || normalized.includes("econnrefused")
|
||||
}
|
||||
|
||||
export function supportsCmuxNotifyFlagModel(helpText: string): boolean {
|
||||
const normalized = helpText.toLowerCase()
|
||||
return normalized.includes("--title") && normalized.includes("--body")
|
||||
}
|
||||
|
||||
function buildTmuxPaneControlProbeArgs(tmuxBinary: string, paneId: string): string[] {
|
||||
return [
|
||||
tmuxBinary,
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
paneId,
|
||||
"#{pane_id}",
|
||||
]
|
||||
}
|
||||
|
||||
function buildCmuxNotifyProbeArgs(input: {
|
||||
cmuxBinary: string
|
||||
workspaceId?: string
|
||||
surfaceId?: string
|
||||
}): string[] {
|
||||
const args = [
|
||||
input.cmuxBinary,
|
||||
"notify",
|
||||
"--title",
|
||||
"capability-probe",
|
||||
"--body",
|
||||
"capability-probe",
|
||||
]
|
||||
|
||||
if (input.workspaceId) {
|
||||
args.push("--workspace", input.workspaceId)
|
||||
}
|
||||
|
||||
if (input.surfaceId) {
|
||||
args.push("--surface", input.surfaceId)
|
||||
}
|
||||
|
||||
args.push("--help")
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function probeTmuxPaneControl(input: {
|
||||
tmuxBinary: string
|
||||
paneId: string
|
||||
environment: Record<string, string | undefined>
|
||||
timeoutMs: number
|
||||
}): Promise<boolean> {
|
||||
const probeResult = await runProbeCommand(
|
||||
buildTmuxPaneControlProbeArgs(input.tmuxBinary, input.paneId),
|
||||
{
|
||||
environment: input.environment,
|
||||
timeoutMs: input.timeoutMs,
|
||||
},
|
||||
)
|
||||
|
||||
if (probeResult.timedOut || probeResult.exitCode !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvedPaneId = normalizeEnvValue(probeResult.stdout)
|
||||
return resolvedPaneId === input.paneId
|
||||
}
|
||||
|
||||
async function runProbeCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
environment?: Record<string, string | undefined>
|
||||
timeoutMs?: number
|
||||
} = {},
|
||||
): Promise<ProbeCommandResult> {
|
||||
try {
|
||||
const proc = spawn([cmd, "tmux"], {
|
||||
const proc = spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: toProbeEnvironment(options.environment),
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
return null
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_EXECUTABLE_CHECK_TIMEOUT_MS
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const timedOut = await Promise.race([
|
||||
proc.exited.then(() => false).catch(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(true)
|
||||
}, timeoutMs)
|
||||
}),
|
||||
])
|
||||
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const path = stdout.trim().split("\n")[0]
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
if (timedOut) {
|
||||
return {
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
}
|
||||
}
|
||||
|
||||
const verifyProc = spawn([path, "-V"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const exitCode = await proc.exited.catch(() => null)
|
||||
const stdout = await new Response(proc.stdout).text().catch(() => "")
|
||||
const stderr = await new Response(proc.stderr).text().catch(() => "")
|
||||
|
||||
const verifyExitCode = await verifyProc.exited
|
||||
if (verifyExitCode !== 0) {
|
||||
return null
|
||||
return {
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
function findCommandPath(
|
||||
commandName: string,
|
||||
environment?: Record<string, string | undefined>,
|
||||
): string | null {
|
||||
try {
|
||||
const probeEnvironment = toProbeEnvironment(environment)
|
||||
const hasExplicitPathOverride =
|
||||
environment !== undefined
|
||||
&& Object.prototype.hasOwnProperty.call(environment, "PATH")
|
||||
const whichOptions = hasExplicitPathOverride
|
||||
? { PATH: probeEnvironment.PATH ?? "" }
|
||||
: probeEnvironment.PATH !== undefined
|
||||
? { PATH: probeEnvironment.PATH }
|
||||
: undefined
|
||||
|
||||
const discovered = Bun.which(commandName, whichOptions)
|
||||
return discovered ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExecutablePath(
|
||||
commandName: string,
|
||||
verifyArgs: string[],
|
||||
environment?: Record<string, string | undefined>,
|
||||
): Promise<string | null> {
|
||||
const discovered = findCommandPath(commandName, environment)
|
||||
if (!discovered) {
|
||||
return null
|
||||
}
|
||||
|
||||
const verification = await runProbeCommand([discovered, ...verifyArgs], {
|
||||
environment,
|
||||
})
|
||||
if (verification.timedOut || verification.exitCode !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return discovered
|
||||
}
|
||||
|
||||
async function findTmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
|
||||
if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveExecutablePath("tmux", ["-V"], environment)
|
||||
}
|
||||
|
||||
async function findCmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
|
||||
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveExecutablePath("cmux", ["--help"], environment)
|
||||
}
|
||||
|
||||
export async function getTmuxPath(): Promise<string | null> {
|
||||
if (tmuxPath !== null) {
|
||||
if (tmuxPathInitialized) {
|
||||
return tmuxPath
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
if (tmuxPathInitPromise) {
|
||||
return tmuxPathInitPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
tmuxPathInitPromise = (async () => {
|
||||
const path = await findTmuxPath()
|
||||
tmuxPath = path
|
||||
tmuxPathInitialized = true
|
||||
return path
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
return tmuxPathInitPromise
|
||||
}
|
||||
|
||||
export function getCachedTmuxPath(): string | null {
|
||||
return tmuxPath
|
||||
}
|
||||
|
||||
export function startBackgroundCheck(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getTmuxPath()
|
||||
initPromise.catch(() => {})
|
||||
export async function getCmuxPath(): Promise<string | null> {
|
||||
if (cmuxPathInitialized) {
|
||||
return cmuxPath
|
||||
}
|
||||
|
||||
if (cmuxPathInitPromise) {
|
||||
return cmuxPathInitPromise
|
||||
}
|
||||
|
||||
cmuxPathInitPromise = (async () => {
|
||||
const path = await findCmuxPath()
|
||||
cmuxPath = path
|
||||
cmuxPathInitialized = true
|
||||
return path
|
||||
})()
|
||||
|
||||
return cmuxPathInitPromise
|
||||
}
|
||||
|
||||
export function getCachedCmuxPath(): string | null {
|
||||
return cmuxPath
|
||||
}
|
||||
|
||||
export async function probeTmuxRuntime(options: ProbeOptions = {}): Promise<TmuxRuntimeProbe> {
|
||||
const environment = options.environment ?? process.env
|
||||
if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) {
|
||||
return {
|
||||
path: null,
|
||||
reachable: false,
|
||||
paneControlReachable: false,
|
||||
explicitDisable: true,
|
||||
}
|
||||
}
|
||||
|
||||
const path = options.environment
|
||||
? await findTmuxPath(environment)
|
||||
: await getTmuxPath()
|
||||
const paneId = normalizeEnvValue(environment.TMUX_PANE)
|
||||
const hasTmuxEnvironment = Boolean(normalizeEnvValue(environment.TMUX))
|
||||
|
||||
if (!path || !hasTmuxEnvironment || !paneId) {
|
||||
return {
|
||||
path,
|
||||
reachable: Boolean(path),
|
||||
paneControlReachable: false,
|
||||
explicitDisable: false,
|
||||
}
|
||||
}
|
||||
|
||||
const paneControlReachable = await probeTmuxPaneControl({
|
||||
tmuxBinary: path,
|
||||
paneId,
|
||||
environment,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TMUX_PANE_CONTROL_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return {
|
||||
path,
|
||||
reachable: Boolean(path),
|
||||
paneControlReachable,
|
||||
explicitDisable: false,
|
||||
}
|
||||
}
|
||||
|
||||
function classifyCmuxProbeFailureKind(result: ProbeCommandResult): CmuxProbeFailureKind {
|
||||
const combinedOutput = `${result.stderr}\n${result.stdout}`
|
||||
return isConnectionRefusedText(combinedOutput)
|
||||
? "connection-refused"
|
||||
: "exit-non-zero"
|
||||
}
|
||||
|
||||
export async function probeCmuxReachability(options: ProbeOptions = {}): Promise<CmuxReachabilityProbe> {
|
||||
const environment = options.environment ?? process.env
|
||||
const socketPath = normalizeEnvValue(environment.CMUX_SOCKET_PATH)
|
||||
const endpointType = classifyCmuxEndpoint(socketPath)
|
||||
const workspaceId = normalizeEnvValue(environment.CMUX_WORKSPACE_ID)
|
||||
const surfaceId = normalizeEnvValue(environment.CMUX_SURFACE_ID)
|
||||
const hintStrength = resolveCmuxHintStrength(environment)
|
||||
|
||||
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
|
||||
return {
|
||||
path: null,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: false,
|
||||
explicitDisable: true,
|
||||
failureKind: "explicit-disable",
|
||||
}
|
||||
}
|
||||
|
||||
const path = options.environment
|
||||
? await findCmuxPath(environment)
|
||||
: await getCmuxPath()
|
||||
if (!path) {
|
||||
return {
|
||||
path: null,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "missing-binary",
|
||||
}
|
||||
}
|
||||
|
||||
if (!socketPath) {
|
||||
return {
|
||||
path,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "missing-socket",
|
||||
}
|
||||
}
|
||||
|
||||
const probeResult = await runProbeCommand([path, "ping"], {
|
||||
environment,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
let effectiveProbeResult = probeResult
|
||||
|
||||
const firstFailureKind = classifyCmuxProbeFailureKind(probeResult)
|
||||
if (
|
||||
!probeResult.timedOut
|
||||
&& probeResult.exitCode !== 0
|
||||
&& endpointType === "relay"
|
||||
&& firstFailureKind === "connection-refused"
|
||||
) {
|
||||
effectiveProbeResult = await runProbeCommand([path, "ping"], {
|
||||
environment,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_PING_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
if (effectiveProbeResult.timedOut) {
|
||||
return {
|
||||
path,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "timeout",
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveProbeResult.exitCode !== 0) {
|
||||
const failureKind = classifyCmuxProbeFailureKind(effectiveProbeResult)
|
||||
|
||||
return {
|
||||
path,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: false,
|
||||
explicitDisable: false,
|
||||
failureKind,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
socketPath,
|
||||
endpointType,
|
||||
workspaceId,
|
||||
surfaceId,
|
||||
hintStrength,
|
||||
reachable: true,
|
||||
explicitDisable: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCmuxNotificationCapability(
|
||||
options: ProbeOptions & {
|
||||
cmuxPath?: string | null
|
||||
workspaceId?: string
|
||||
surfaceId?: string
|
||||
} = {},
|
||||
): Promise<CmuxNotificationCapabilityProbe> {
|
||||
const environment = options.environment ?? process.env
|
||||
|
||||
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
|
||||
return {
|
||||
capable: false,
|
||||
explicitDisable: true,
|
||||
failureKind: "explicit-disable",
|
||||
}
|
||||
}
|
||||
|
||||
const cmuxBinary = options.cmuxPath
|
||||
?? (options.environment ? await findCmuxPath(environment) : await getCmuxPath())
|
||||
if (!cmuxBinary) {
|
||||
return {
|
||||
capable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "missing-binary",
|
||||
}
|
||||
}
|
||||
|
||||
const probeResult = await runProbeCommand(buildCmuxNotifyProbeArgs({
|
||||
cmuxBinary,
|
||||
workspaceId: normalizeEnvValue(options.workspaceId),
|
||||
surfaceId: normalizeEnvValue(options.surfaceId),
|
||||
}), {
|
||||
environment,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_CMUX_NOTIFY_CAPABILITY_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
if (probeResult.timedOut) {
|
||||
return {
|
||||
capable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "timeout",
|
||||
}
|
||||
}
|
||||
|
||||
if (probeResult.exitCode !== 0) {
|
||||
return {
|
||||
capable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "exit-non-zero",
|
||||
}
|
||||
}
|
||||
|
||||
const helpOutput = `${probeResult.stdout}\n${probeResult.stderr}`
|
||||
if (!supportsCmuxNotifyFlagModel(helpOutput)) {
|
||||
return {
|
||||
capable: false,
|
||||
explicitDisable: false,
|
||||
failureKind: "unsupported-contract",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capable: true,
|
||||
explicitDisable: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCmuxRuntime(options: ProbeOptions = {}): Promise<CmuxRuntimeProbe> {
|
||||
const reachability = await probeCmuxReachability(options)
|
||||
const capability = await probeCmuxNotificationCapability({
|
||||
...options,
|
||||
cmuxPath: reachability.path,
|
||||
workspaceId: reachability.workspaceId,
|
||||
surfaceId: reachability.surfaceId,
|
||||
})
|
||||
|
||||
return {
|
||||
...reachability,
|
||||
notifyCapable: capability.capable,
|
||||
notifyFailureKind: capability.failureKind,
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackgroundCheck(): void {
|
||||
if (!tmuxPathInitPromise) {
|
||||
tmuxPathInitPromise = getTmuxPath()
|
||||
tmuxPathInitPromise.catch(() => {})
|
||||
}
|
||||
if (!cmuxPathInitPromise) {
|
||||
cmuxPathInitPromise = getCmuxPath()
|
||||
cmuxPathInitPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMultiplexerPathCacheForTesting(): void {
|
||||
tmuxPath = null
|
||||
tmuxPathInitialized = false
|
||||
tmuxPathInitPromise = null
|
||||
|
||||
cmuxPath = null
|
||||
cmuxPathInitialized = false
|
||||
cmuxPathInitPromise = null
|
||||
}
|
||||
|
||||
162
src/tools/interactive-bash/tools.test.ts
Normal file
162
src/tools/interactive-bash/tools.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import {
|
||||
resetResolvedMultiplexerRuntimeForTesting,
|
||||
setResolvedMultiplexerRuntime,
|
||||
type ResolvedMultiplexer,
|
||||
} from "../../shared/tmux"
|
||||
import { createInteractiveBashTool, interactive_bash } from "./tools"
|
||||
import * as tmuxPathResolver from "./tmux-path-resolver"
|
||||
|
||||
const mockToolContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "msg-1",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
function createTmuxEnabledRuntime(): ResolvedMultiplexer {
|
||||
return {
|
||||
platform: process.platform,
|
||||
mode: "tmux-only",
|
||||
paneBackend: "tmux",
|
||||
notificationBackend: "desktop",
|
||||
tmux: {
|
||||
path: "/usr/bin/tmux",
|
||||
reachable: true,
|
||||
insideEnvironment: true,
|
||||
paneId: "%1",
|
||||
explicitDisable: false,
|
||||
},
|
||||
cmux: {
|
||||
path: null,
|
||||
reachable: false,
|
||||
notifyCapable: false,
|
||||
socketPath: undefined,
|
||||
endpointType: "missing",
|
||||
workspaceId: undefined,
|
||||
surfaceId: undefined,
|
||||
hintStrength: "none",
|
||||
explicitDisable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPaneUnavailableRuntime(): ResolvedMultiplexer {
|
||||
return {
|
||||
platform: process.platform,
|
||||
mode: "cmux-notify-only",
|
||||
paneBackend: "none",
|
||||
notificationBackend: "cmux",
|
||||
tmux: {
|
||||
path: "/usr/bin/tmux",
|
||||
reachable: false,
|
||||
insideEnvironment: false,
|
||||
paneId: undefined,
|
||||
explicitDisable: false,
|
||||
},
|
||||
cmux: {
|
||||
path: "/usr/local/bin/cmux",
|
||||
reachable: true,
|
||||
notifyCapable: true,
|
||||
socketPath: "/tmp/cmux.sock",
|
||||
endpointType: "unix",
|
||||
workspaceId: "workspace-1",
|
||||
surfaceId: "surface-1",
|
||||
hintStrength: "strong",
|
||||
explicitDisable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("interactive_bash runtime resolution", () => {
|
||||
afterEach(() => {
|
||||
resetResolvedMultiplexerRuntimeForTesting()
|
||||
})
|
||||
|
||||
test("createInteractiveBashTool without runtime resolves current runtime on execute", async () => {
|
||||
resetResolvedMultiplexerRuntimeForTesting()
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
const tool = createInteractiveBashTool()
|
||||
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
|
||||
|
||||
const result = await tool.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
|
||||
|
||||
expect(result).toBe("Error: tmux executable is not reachable")
|
||||
} finally {
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("interactive_bash singleton resolves current runtime on execute", async () => {
|
||||
resetResolvedMultiplexerRuntimeForTesting()
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
|
||||
|
||||
const result = await interactive_bash.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
|
||||
|
||||
expect(result).toBe("Error: tmux executable is not reachable")
|
||||
} finally {
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("allows detached new-session commands when pane control is unavailable", async () => {
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
|
||||
|
||||
const result = await tool.execute(
|
||||
{ tmux_command: "new-session -d -s omo-dev" },
|
||||
mockToolContext,
|
||||
)
|
||||
|
||||
expect(result).toBe("Error: tmux executable is not reachable")
|
||||
expect(getTmuxPathSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("allows targeted tmux commands when pane control is unavailable", async () => {
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
|
||||
|
||||
const result = await tool.execute(
|
||||
{ tmux_command: "send-keys -t omo-dev \"vim\" Enter" },
|
||||
mockToolContext,
|
||||
)
|
||||
|
||||
expect(result).toBe("Error: tmux executable is not reachable")
|
||||
expect(getTmuxPathSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("blocks untargeted pane-control commands when pane backend is unavailable", async () => {
|
||||
const getTmuxPathSpy = spyOn(tmuxPathResolver, "getTmuxPath").mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
const tool = createInteractiveBashTool(createPaneUnavailableRuntime())
|
||||
|
||||
const result = await tool.execute(
|
||||
{ tmux_command: "send-keys \"vim\" Enter" },
|
||||
mockToolContext,
|
||||
)
|
||||
|
||||
expect(result).toBe(
|
||||
"Error: interactive_bash is TMUX-only and pane control is unavailable in 'cmux-notify-only' runtime.",
|
||||
)
|
||||
expect(getTmuxPathSpy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
getTmuxPathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,14 +53,57 @@ export function tokenizeCommand(cmd: string): string[] {
|
||||
return tokens
|
||||
}
|
||||
|
||||
export const interactive_bash: ToolDefinition = tool({
|
||||
function hasTmuxTargetFlag(tokens: string[]): boolean {
|
||||
return tokens.some((token, index) => {
|
||||
if (token === "-t") {
|
||||
return typeof tokens[index + 1] === "string" && tokens[index + 1].length > 0
|
||||
}
|
||||
|
||||
return token.startsWith("-t") && token.length > 2
|
||||
})
|
||||
}
|
||||
|
||||
function hasDetachedFlag(tokens: string[]): boolean {
|
||||
return tokens.some((token) => {
|
||||
if (token === "-d") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!token.startsWith("-") || token.startsWith("--")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return token.slice(1).includes("d")
|
||||
})
|
||||
}
|
||||
|
||||
function canRunWithoutPaneControl(tokens: string[]): boolean {
|
||||
const subcommand = tokens[0]?.toLowerCase()
|
||||
if (!subcommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isDetachedNewSession =
|
||||
(subcommand === "new-session" || subcommand === "new")
|
||||
&& hasDetachedFlag(tokens)
|
||||
|
||||
return isDetachedNewSession || hasTmuxTargetFlag(tokens)
|
||||
}
|
||||
|
||||
export function createInteractiveBashTool(
|
||||
runtime?: ResolvedMultiplexer,
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: INTERACTIVE_BASH_DESCRIPTION,
|
||||
args: {
|
||||
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const tmuxPath = getCachedTmuxPath() ?? "tmux"
|
||||
const resolvedRuntime =
|
||||
runtime
|
||||
?? getResolvedMultiplexerRuntime()
|
||||
?? createDisabledMultiplexerRuntime()
|
||||
|
||||
const parts = tokenizeCommand(args.tmux_command)
|
||||
|
||||
@@ -63,6 +111,15 @@ export const interactive_bash: ToolDefinition = tool({
|
||||
return "Error: Empty tmux command"
|
||||
}
|
||||
|
||||
if (resolvedRuntime.paneBackend !== "tmux" && !canRunWithoutPaneControl(parts)) {
|
||||
return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${resolvedRuntime.mode}' runtime.`
|
||||
}
|
||||
|
||||
const tmuxPath = await getTmuxPath()
|
||||
if (!tmuxPath) {
|
||||
return "Error: tmux executable is not reachable"
|
||||
}
|
||||
|
||||
const subcommand = parts[0].toLowerCase()
|
||||
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
|
||||
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
|
||||
@@ -133,4 +190,7 @@ The Bash tool can execute these commands directly. Do NOT retry with interactive
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const interactive_bash: ToolDefinition = createInteractiveBashTool()
|
||||
|
||||
Reference in New Issue
Block a user