Compare commits

..

5 Commits

Author SHA1 Message Date
github-actions[bot]
3dc11ea620 @HOYALIM has signed the CLA in code-yeongyu/oh-my-openagent#2935 2026-03-29 07:31:49 +00:00
github-actions[bot]
5a28ee1bef @quangtran88 has signed the CLA in code-yeongyu/oh-my-openagent#2929 2026-03-29 03:21:54 +00:00
github-actions[bot]
5d4e57ce96 @lorenzo-dallamuta has signed the CLA in code-yeongyu/oh-my-openagent#2925 2026-03-28 21:43:40 +00:00
YeonGyu-Kim
b2497f1327 fix: resolve 3 community-reported bugs (#2915, #2917, #2918)
- background_output: snapshot read cursor before consuming, restore on
  /undo message removal so re-reads return data (fixes #2915)
- MCP loader: preserve oauth field in transformMcpServer, add scope/
  projectPath filtering so local-scoped MCPs only load in matching
  directories (fixes #2917)
- runtime-fallback: add 'reached your usage limit' to retryable error
  patterns so quota exhaustion triggers model fallback (fixes #2918)

Verified: bun test (4606 pass / 0 fail), tsc --noEmit clean
2026-03-29 04:53:43 +09:00
github-actions[bot]
9fc56ab544 @ryandielhenn has signed the CLA in code-yeongyu/oh-my-openagent#2919 2026-03-28 17:47:04 +00:00
50 changed files with 788 additions and 2967 deletions

View File

@@ -23,7 +23,6 @@ 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)
@@ -566,61 +565,6 @@ 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:
@@ -1026,14 +970,9 @@ 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 |
| Variable | Description |
| --------------------- | ----------------------------------------------------------------- |
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) |
### Provider-Specific

View File

@@ -2367,6 +2367,38 @@
"created_at": "2026-03-28T12:20:42Z",
"repoId": 1108837393,
"pullRequestNo": 2916
},
{
"name": "ryandielhenn",
"id": 35785891,
"comment_id": 4148508024,
"created_at": "2026-03-28T17:46:50Z",
"repoId": 1108837393,
"pullRequestNo": 2919
},
{
"name": "lorenzo-dallamuta",
"id": 66994937,
"comment_id": 4148848505,
"created_at": "2026-03-28T21:30:15Z",
"repoId": 1108837393,
"pullRequestNo": 2925
},
{
"name": "quangtran88",
"id": 107824159,
"comment_id": 4149327240,
"created_at": "2026-03-29T03:21:39Z",
"repoId": 1108837393,
"pullRequestNo": 2929
},
{
"name": "HOYALIM",
"id": 166576253,
"comment_id": 4149626853,
"created_at": "2026-03-29T07:31:36Z",
"repoId": 1108837393,
"pullRequestNo": 2935
}
]
}

View File

@@ -4,7 +4,6 @@ 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"
@@ -35,7 +34,6 @@ export function createHooks(args: {
safeHookEnabled: boolean
mergedSkills: LoadedSkill[]
availableSkills: AvailableSkill[]
resolvedMultiplexer: ResolvedMultiplexer
}) {
const {
ctx,
@@ -46,7 +44,6 @@ export function createHooks(args: {
safeHookEnabled,
mergedSkills,
availableSkills,
resolvedMultiplexer,
} = args
const core = createCoreHooks({
@@ -55,7 +52,6 @@ export function createHooks(args: {
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
})
const continuation = createContinuationHooks({

View File

@@ -10,11 +10,9 @@ 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
@@ -25,21 +23,13 @@ export function createManagers(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
tmuxConfig: TmuxConfig
resolvedMultiplexer: ResolvedMultiplexer
modelCacheState: ModelCacheState
backgroundNotificationHookEnabled: boolean
}): Managers {
const {
ctx,
pluginConfig,
tmuxConfig,
resolvedMultiplexer,
modelCacheState,
backgroundNotificationHookEnabled,
} = args
const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args
markServerRunningInProcess()
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, resolvedMultiplexer)
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
registerManagerForCleanup({
shutdown: async () => {
@@ -54,18 +44,13 @@ export function createManagers(args: {
pluginConfig.background_task,
{
tmuxConfig,
resolvedMultiplexer,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
})
if (resolvedMultiplexer.paneBackend !== "tmux") {
return
}
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
@@ -99,7 +84,6 @@ export function createManagers(args: {
})
return {
resolvedMultiplexer,
tmuxSessionManager,
backgroundManager,
skillMcpManager,

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import type {
} from "./types"
import { transformMcpServer } from "./transformer"
import { log } from "../../shared/logger"
import { shouldLoadMcpServer } from "./scope-filter"
interface McpConfigPath {
path: string
@@ -75,6 +76,7 @@ export async function loadMcpConfigs(
const loadedServers: LoadedMcpServer[] = []
const paths = getMcpConfigPaths()
const disabledSet = new Set(disabledMcps)
const cwd = process.cwd()
for (const { path, scope } of paths) {
const config = await loadMcpConfigFile(path)
@@ -86,6 +88,15 @@ export async function loadMcpConfigs(
continue
}
if (!shouldLoadMcpServer(serverConfig, cwd)) {
log(`Skipping MCP server "${name}" because local scope does not match cwd`, {
path,
projectPath: serverConfig.projectPath,
cwd,
})
continue
}
if (serverConfig.disabled) {
log(`Disabling MCP server "${name}"`, { path })
delete servers[name]

View File

@@ -0,0 +1,28 @@
import { existsSync, realpathSync } from "fs"
import { resolve } from "path"
import type { ClaudeCodeMcpServer } from "./types"
function normalizePath(path: string): string {
const resolvedPath = resolve(path)
if (!existsSync(resolvedPath)) {
return resolvedPath
}
return realpathSync(resolvedPath)
}
export function shouldLoadMcpServer(
server: Pick<ClaudeCodeMcpServer, "scope" | "projectPath">,
cwd = process.cwd()
): boolean {
if (server.scope !== "local") {
return true
}
if (!server.projectPath) {
return false
}
return normalizePath(server.projectPath) === normalizePath(cwd)
}

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
const TEST_DIR = join(tmpdir(), `mcp-scope-filtering-test-${Date.now()}`)
const TEST_HOME = join(TEST_DIR, "home")
describe("loadMcpConfigs", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_HOME, { recursive: true })
mock.module("os", () => ({
homedir: () => TEST_HOME,
tmpdir,
}))
mock.module("../../shared", () => ({
getClaudeConfigDir: () => join(TEST_HOME, ".claude"),
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
})
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("#given user-scoped MCP entries with local scope metadata", () => {
it("#when loading configs #then only servers matching the current project path are loaded", async () => {
writeFileSync(
join(TEST_HOME, ".claude.json"),
JSON.stringify({
mcpServers: {
globalServer: {
command: "npx",
args: ["global-server"],
},
matchingLocal: {
command: "npx",
args: ["matching-local"],
scope: "local",
projectPath: TEST_DIR,
},
nonMatchingLocal: {
command: "npx",
args: ["non-matching-local"],
scope: "local",
projectPath: join(TEST_DIR, "other-project"),
},
missingProjectPath: {
command: "npx",
args: ["missing-project-path"],
scope: "local",
},
},
})
)
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const { loadMcpConfigs } = await import("./loader")
const result = await loadMcpConfigs()
expect(result.servers).toHaveProperty("globalServer")
expect(result.servers).toHaveProperty("matchingLocal")
expect(result.servers).not.toHaveProperty("nonMatchingLocal")
expect(result.servers).not.toHaveProperty("missingProjectPath")
expect(result.loadedServers.map((server) => server.name)).toEqual([
"globalServer",
"matchingLocal",
])
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test"
import { transformMcpServer } from "./transformer"
describe("transformMcpServer", () => {
describe("#given a remote MCP server with oauth config", () => {
it("#when transforming the server #then preserves oauth on the remote config", () => {
const transformed = transformMcpServer("remote-oauth", {
type: "http",
url: "https://mcp.example.com",
headers: { Authorization: "Bearer test" },
oauth: {
clientId: "client-id",
scopes: ["read", "write"],
},
})
expect(transformed).toEqual({
type: "remote",
url: "https://mcp.example.com",
headers: { Authorization: "Bearer test" },
oauth: {
clientId: "client-id",
scopes: ["read", "write"],
},
enabled: true,
})
})
})
})

View File

@@ -30,6 +30,10 @@ export function transformMcpServer(
config.headers = expanded.headers
}
if (expanded.oauth && Object.keys(expanded.oauth).length > 0) {
config.oauth = expanded.oauth
}
return config
}

View File

@@ -1,5 +1,10 @@
export type McpScope = "user" | "project" | "local"
export interface McpOAuthConfig {
clientId?: string
scopes?: string[]
}
export interface ClaudeCodeMcpServer {
type?: "http" | "sse" | "stdio"
url?: string
@@ -7,10 +12,9 @@ export interface ClaudeCodeMcpServer {
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
oauth?: {
clientId?: string
scopes?: string[]
}
oauth?: McpOAuthConfig
scope?: McpScope
projectPath?: string
disabled?: boolean
}
@@ -29,6 +33,7 @@ export interface McpRemoteConfig {
type: "remote"
url: string
headers?: Record<string, string>
oauth?: McpOAuthConfig
enabled?: boolean
}

View File

@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import type { LoadedPlugin } from "./types"
const TEST_DIR = join(tmpdir(), `plugin-mcp-loader-test-${Date.now()}`)
const PROJECT_DIR = join(TEST_DIR, "project")
const PLUGIN_DIR = join(TEST_DIR, "plugin")
const MCP_CONFIG_PATH = join(PLUGIN_DIR, "mcp.json")
describe("loadPluginMcpServers", () => {
beforeEach(() => {
mkdirSync(PROJECT_DIR, { recursive: true })
mkdirSync(PLUGIN_DIR, { recursive: true })
mock.module("../../shared/logger", () => ({
log: () => {},
}))
})
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("#given plugin MCP entries with local scope metadata", () => {
it("#when loading plugin MCP servers #then only entries matching the current cwd are included", async () => {
writeFileSync(
MCP_CONFIG_PATH,
JSON.stringify({
mcpServers: {
globalServer: {
command: "npx",
args: ["global-plugin-server"],
},
matchingLocal: {
command: "npx",
args: ["matching-plugin-local"],
scope: "local",
projectPath: PROJECT_DIR,
},
nonMatchingLocal: {
command: "npx",
args: ["non-matching-plugin-local"],
scope: "local",
projectPath: join(PROJECT_DIR, "other-project"),
},
},
})
)
const plugin: LoadedPlugin = {
name: "demo-plugin",
version: "1.0.0",
scope: "project",
installPath: PLUGIN_DIR,
pluginKey: "demo-plugin@test",
mcpPath: MCP_CONFIG_PATH,
}
const originalCwd = process.cwd()
process.chdir(PROJECT_DIR)
try {
const { loadPluginMcpServers } = await import("./mcp-server-loader")
const servers = await loadPluginMcpServers([plugin])
expect(servers).toHaveProperty("demo-plugin:globalServer")
expect(servers).toHaveProperty("demo-plugin:matchingLocal")
expect(servers).not.toHaveProperty("demo-plugin:nonMatchingLocal")
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -1,6 +1,7 @@
import { existsSync } from "fs"
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { shouldLoadMcpServer } from "../claude-code-mcp-loader/scope-filter"
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"
import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types"
import { log } from "../../shared/logger"
@@ -11,6 +12,7 @@ export async function loadPluginMcpServers(
plugins: LoadedPlugin[],
): Promise<Record<string, McpServerConfig>> {
const servers: Record<string, McpServerConfig> = {}
const cwd = process.cwd()
for (const plugin of plugins) {
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
@@ -25,6 +27,15 @@ export async function loadPluginMcpServers(
if (!config.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (!shouldLoadMcpServer(serverConfig, cwd)) {
log(`Skipping local plugin MCP server "${name}" outside current cwd`, {
path: plugin.mcpPath,
projectPath: serverConfig.projectPath,
cwd,
})
continue
}
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`)
continue

View File

@@ -3,7 +3,6 @@ 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 = {
@@ -41,8 +40,6 @@ const mockTmuxDeps: TmuxUtilDeps = {
getCurrentPaneId: mockGetCurrentPaneId,
}
let mockedResolvedMultiplexerRuntime: ResolvedMultiplexer | null = null
mock.module('./pane-state-querier', () => ({
queryWindowState: mockQueryWindowState,
paneExists: mockPaneExists,
@@ -64,13 +61,8 @@ 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,
@@ -143,7 +135,6 @@ describe('TmuxSessionManager', () => {
mockExecuteAction.mockClear()
mockIsInsideTmux.mockClear()
mockGetCurrentPaneId.mockClear()
mockedResolvedMultiplexerRuntime = null
trackedSessions.clear()
mockQueryWindowState.mockImplementation(async () => createWindowState())
@@ -236,54 +227,6 @@ 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)

View File

@@ -3,14 +3,12 @@ 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"
@@ -34,38 +32,6 @@ 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,
@@ -101,25 +67,11 @@ export class TmuxSessionManager {
private deferredAttachTickScheduled = false
private nullStateCount = 0
private deps: TmuxUtilDeps
private resolvedMultiplexer: ResolvedMultiplexer
private pollingManager: TmuxPollingManager
constructor(
ctx: PluginInput,
tmuxConfig: TmuxConfig,
runtimeOrDeps: ResolvedMultiplexer | TmuxUtilDeps = createDisabledMultiplexerRuntime(),
deps: TmuxUtilDeps = defaultTmuxDeps,
) {
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, 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
}
this.deps = deps
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
const fallbackUrl = `http://localhost:${defaultPort}`
try {
@@ -134,7 +86,7 @@ export class TmuxSessionManager {
} catch {
this.serverUrl = fallbackUrl
}
this.sourcePaneId = this.resolvedMultiplexer.tmux.paneId ?? this.deps.getCurrentPaneId()
this.sourcePaneId = deps.getCurrentPaneId()
this.pollingManager = new TmuxPollingManager(
this.client,
this.sessions,
@@ -145,12 +97,10 @@ 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.resolvedMultiplexer.paneBackend === "tmux"
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
private getCapacityConfig(): CapacityConfig {
@@ -490,8 +440,7 @@ export class TmuxSessionManager {
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: this.tmuxConfig.enabled,
isInsideTmux: this.resolvedMultiplexer.paneBackend === "tmux",
multiplexerMode: this.resolvedMultiplexer.mode,
isInsideTmux: this.deps.isInsideTmux(),
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,

View File

@@ -1,189 +0,0 @@
import { describe, expect, test } from "bun:test"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
createCmuxNotificationAdapter,
type CmuxNotifyCommandResult,
} from "./cmux-notification-adapter"
import type { ResolvedMultiplexer } from "../shared/tmux"
function createResolvedMultiplexer(): ResolvedMultiplexer {
return {
platform: "darwin",
mode: "cmux-shim",
paneBackend: "tmux",
notificationBackend: "cmux",
tmux: {
path: "/usr/bin/tmux",
reachable: true,
insideEnvironment: true,
paneId: "%1",
explicitDisable: false,
},
cmux: {
path: "/usr/local/bin/cmux",
reachable: true,
notifyCapable: true,
socketPath: "/tmp/cmux.sock",
endpointType: "unix",
workspaceId: "workspace-1",
surfaceId: "surface-1",
hintStrength: "strong",
explicitDisable: false,
},
}
}
function createResult(overrides: Partial<CmuxNotifyCommandResult> = {}): CmuxNotifyCommandResult {
return {
exitCode: 0,
stdout: "",
stderr: "",
timedOut: false,
...overrides,
}
}
describe("cmux notification adapter", () => {
test("delivers via cmux when notify command succeeds", async () => {
let callCount = 0
let receivedArgs: string[] = []
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async (input) => {
callCount += 1
receivedArgs = input.args
return createResult()
},
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(true)
expect(callCount).toBe(1)
expect(receivedArgs).toEqual([
"/usr/local/bin/cmux",
"notify",
"--title",
"OpenCode",
"--body",
"Task complete",
"--workspace",
"workspace-1",
"--surface",
"surface-1",
])
expect(adapter.hasDowngraded()).toBe(false)
})
test("falls back to desktop when cmux notify exits non-zero", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 2,
stderr: "notify failed",
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("falls back to desktop when cmux notify times out", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
timedOut: true,
exitCode: null,
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("falls back to desktop when output reports connection-refused", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 0,
stderr: "dial tcp 127.0.0.1:7777: connect: connection refused",
}),
})
const delivered = await adapter.send("OpenCode", "Task complete")
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
})
test("downgrades permanently after first cmux notify failure", async () => {
let callCount = 0
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => {
callCount += 1
return createResult({
exitCode: 1,
stderr: "notify failed",
})
},
})
const firstDelivered = await adapter.send("OpenCode", "First")
const secondDelivered = await adapter.send("OpenCode", "Second")
expect(firstDelivered).toBe(false)
expect(secondDelivered).toBe(false)
expect(callCount).toBe(1)
})
test("returns promptly on timeout when cmux process ignores TERM", async () => {
if (process.platform === "win32") {
return
}
const tempDirectory = mkdtempSync(join(tmpdir(), "cmux-notify-timeout-"))
const fakeCmuxPath = join(tempDirectory, "cmux")
const slowCmuxScript = `#!/bin/sh
if [ "$1" = "notify" ]; then
trap '' TERM
/bin/sleep 1
exit 0
fi
exit 1
`
writeFileSync(fakeCmuxPath, slowCmuxScript)
chmodSync(fakeCmuxPath, 0o755)
const runtime = createResolvedMultiplexer()
runtime.cmux.path = fakeCmuxPath
try {
const adapter = createCmuxNotificationAdapter({
runtime,
environment: {
PATH: tempDirectory,
},
timeoutMs: 40,
})
const startedAt = Date.now()
const delivered = await adapter.send("OpenCode", "Task complete")
const elapsedMs = Date.now() - startedAt
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
expect(elapsedMs).toBeLessThan(500)
} finally {
rmSync(tempDirectory, { recursive: true, force: true })
}
})
})

View File

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

View File

@@ -27,6 +27,7 @@ export const RETRYABLE_ERROR_PATTERNS = [
/too.?many.?requests/i,
/quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i,
/(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/exhausted\s+your\s+capacity/i,

View File

@@ -253,6 +253,17 @@ describe("quota error detection (fixes #2747)", () => {
expect(retryable).toBe(true)
})
test("treats hard usage-limit wording as retryable", () => {
//#given
const error = { message: "You've reached your usage limit for this month. Please upgrade to continue." }
//#when
const retryable = isRetryableError(error, [429, 503])
//#then
expect(retryable).toBe(true)
})
test("classifies QuotaExceededError by errorName even without quota keywords in message", () => {
//#given
const error = { name: "QuotaExceededError", message: "Request failed." }

View File

@@ -64,6 +64,11 @@ describe("runtime-fallback", () => {
function createMockPluginConfigWithCategoryFallback(fallbackModels: string[]): OhMyOpenCodeConfig {
return {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: fallbackModels,
@@ -79,6 +84,11 @@ describe("runtime-fallback", () => {
variant?: string,
): OhMyOpenCodeConfig {
return {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
[categoryName]: {
model,
@@ -272,6 +282,39 @@ describe("runtime-fallback", () => {
expect(errorLog).toBeDefined()
})
test("should trigger fallback when session.error says you've reached your usage limit", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]),
})
const sessionID = "test-session-usage-limit"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "kimi-for-coding/k2p5" } },
},
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: { message: "You've reached your usage limit for this month. Please upgrade to continue." },
},
},
})
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "kimi-for-coding/k2p5", to: "zai-coding-plan/glm-5.1" })
const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable"))
expect(skipLog).toBeUndefined()
})
test("should continue fallback chain when fallback model is not found", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
@@ -767,7 +810,13 @@ describe("runtime-fallback", () => {
test("should log when no fallback models configured", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig(),
pluginConfig: {},
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
},
})
const sessionID = "test-session-no-fallbacks"
@@ -2299,6 +2348,11 @@ describe("runtime-fallback", () => {
describe("fallback models configuration", () => {
function createMockPluginConfigWithAgentFallback(agentName: string, fallbackModels: string[]): OhMyOpenCodeConfig {
return {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
agents: {
[agentName]: {
fallback_models: fallbackModels,
@@ -2496,6 +2550,11 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
@@ -2548,6 +2607,11 @@ describe("runtime-fallback", () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
@@ -2605,6 +2669,11 @@ describe("runtime-fallback", () => {
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
@@ -2647,6 +2716,11 @@ describe("runtime-fallback", () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: {
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
categories: {
test: {
fallback_models: ["provider-a/model-a", "provider-b/model-b"],

View File

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

View File

@@ -16,7 +16,7 @@ export function createIdleNotificationScheduler(options: {
platform: Platform
config: SessionNotificationConfig
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
}) {
const notifiedSessions = new Set<string>()
@@ -48,6 +48,12 @@ 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) => {
@@ -83,15 +89,6 @@ 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)
@@ -99,7 +96,7 @@ export function createIdleNotificationScheduler(options: {
return
}
if (hasStaleNotificationVersion(sessionID, version)) {
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
@@ -122,27 +119,13 @@ export function createIdleNotificationScheduler(options: {
try {
if (options.config.skipIfIncompleteTodos) {
const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID)
if (hasStaleNotificationVersion(sessionID, version)) {
if (notificationVersions.get(sessionID) !== version) {
return
}
if (hasPendingWork) return
}
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)) {
if (notificationVersions.get(sessionID) !== version) {
return
}
@@ -153,6 +136,8 @@ 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)
}

View File

@@ -3,8 +3,6 @@ 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
@@ -35,42 +33,6 @@ 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
@@ -425,233 +387,6 @@ 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 = {

View File

@@ -10,15 +10,6 @@ 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
@@ -39,24 +30,10 @@ interface SessionNotificationConfig {
}
export function createSessionNotification(
ctx: PluginInput,
config: SessionNotificationConfig = {},
options: {
resolvedMultiplexer?: ResolvedMultiplexer
cmuxNotificationAdapter?: CmuxNotificationAdapter
} = {},
config: SessionNotificationConfig = {}
) {
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)
@@ -84,25 +61,13 @@ 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 true
return
}
const content = await buildReadyNotificationContent(hookCtx, {
@@ -111,17 +76,7 @@ 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,
})
@@ -179,13 +134,7 @@ export function createSessionNotification(
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const cannotDeliverOnUnsupportedPlatform =
currentPlatform === "unsupported" && !cmuxNotificationAdapter.canSendViaCmux()
const shouldFastExitUnsupportedEvent =
cannotDeliverOnUnsupportedPlatform
&& (event.type !== "session.idle" || !cmuxNotificationAdapter.hasDowngraded())
if (shouldFastExitUnsupportedEvent) return
if (currentPlatform === "unsupported") return
const props = event.properties as Record<string, unknown> | undefined
@@ -223,18 +172,12 @@ export function createSessionNotification(
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
const deliveredViaCmux = await cmuxNotificationAdapter.send(
await sessionNotificationSender.sendSessionNotification(
ctx,
currentPlatform,
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)
}
@@ -256,10 +199,7 @@ 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)
}
await sessionNotificationSender.sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}

View File

@@ -12,14 +12,9 @@ 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,
resolveMultiplexerRuntime,
setResolvedMultiplexerRuntime,
} from "./shared"
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startTmuxCheck } from "./tools"
let activePluginDispose: PluginDispose | null = null
@@ -38,6 +33,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}
injectServerAuthIntoClient(ctx.client)
startTmuxCheck()
await activePluginDispose?.()
const pluginConfig = loadPluginConfig(ctx.directory, ctx)
@@ -58,17 +54,10 @@ 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"),
})
@@ -88,7 +77,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
resolvedMultiplexer,
})
const dispose = createPluginDispose({

View File

@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { OpenClawConfig } from "../types"
const wakeGatewayMock = mock(async () => ({
gateway: "http-gateway",
success: true,
statusCode: 200,
}))
const wakeCommandGatewayMock = mock(async () => ({
gateway: "command-gateway",
success: true,
}))
const interpolateInstructionMock = mock((template: string) => template)
const getCurrentTmuxSessionMock = mock(async () => "workspace-main")
const captureTmuxPaneMock = mock(async () => null)
const startReplyListenerMock = mock(async () => {})
const stopReplyListenerMock = mock(() => {})
mock.module("../dispatcher", () => ({
wakeGateway: wakeGatewayMock,
wakeCommandGateway: wakeCommandGatewayMock,
interpolateInstruction: interpolateInstructionMock,
}))
mock.module("../tmux", () => ({
getCurrentTmuxSession: getCurrentTmuxSessionMock,
captureTmuxPane: captureTmuxPaneMock,
}))
mock.module("../reply-listener", () => ({
startReplyListener: startReplyListenerMock,
stopReplyListener: stopReplyListenerMock,
}))
const { wakeOpenClaw } = await import("../index")
describe("wakeOpenClaw tmux session resolution", () => {
beforeEach(() => {
wakeGatewayMock.mockClear()
wakeCommandGatewayMock.mockClear()
interpolateInstructionMock.mockClear()
getCurrentTmuxSessionMock.mockClear()
captureTmuxPaneMock.mockClear()
startReplyListenerMock.mockClear()
stopReplyListenerMock.mockClear()
})
test("awaits asynchronous tmux session lookup before dispatch", async () => {
const config: OpenClawConfig = {
enabled: true,
gateways: {
commandGateway: {
type: "command",
method: "POST",
command: "echo {{tmuxSession}}",
},
},
hooks: {
"session-start": {
enabled: true,
gateway: "commandGateway",
instruction: "tmux session: {{tmuxSession}}",
},
},
}
await wakeOpenClaw(config, "session-start", {})
expect(getCurrentTmuxSessionMock).toHaveBeenCalledTimes(1)
expect(interpolateInstructionMock).toHaveBeenCalledTimes(1)
expect(interpolateInstructionMock.mock.calls[0]?.[1]?.tmuxSession).toBe("workspace-main")
expect(wakeCommandGatewayMock).toHaveBeenCalledTimes(1)
expect(wakeCommandGatewayMock.mock.calls[0]?.[2]?.tmuxSession).toBe("workspace-main")
})
})

View File

@@ -1,19 +1,7 @@
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"
import { describe, expect, test } from "bun:test"
import { analyzePaneContent } from "../tmux"
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)
@@ -22,35 +10,4 @@ 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()
}
})
})

View File

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

View File

@@ -1,33 +1,16 @@
import { spawn } from "bun"
import { getTmuxPath } from "../tools/interactive-bash/tmux-path-resolver"
import {
getCurrentPaneId,
getResolvedMultiplexerRuntime,
isInsideTmux,
} from "../shared/tmux"
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 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 getTmuxSessionName(): Promise<string | null> {
try {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return null
const proc = spawn([tmuxPath, "display-message", "-p", "#S"], {
const proc = spawn(["tmux", "display-message", "-p", "#S"], {
stdout: "pipe",
stderr: "ignore",
})
@@ -44,11 +27,8 @@ 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(
[tmuxPath, "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
{
stdout: "pipe",
stderr: "ignore",
@@ -66,10 +46,7 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
try {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return false
const literalProc = spawn([tmuxPath, "send-keys", "-t", paneId, "-l", "--", text], {
const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], {
stdout: "ignore",
stderr: "ignore",
})
@@ -78,7 +55,7 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
if (!confirm) return true
const enterProc = spawn([tmuxPath, "send-keys", "-t", paneId, "Enter"], {
const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], {
stdout: "ignore",
stderr: "ignore",
})
@@ -90,16 +67,8 @@ 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 tmuxPath = await getTmuxPath()
if (!tmuxPath) return false
const proc = spawn([tmuxPath, "-V"], {
const proc = spawn(["tmux", "-V"], {
stdout: "ignore",
stderr: "ignore",
})

View File

@@ -17,6 +17,11 @@ import {
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
import {
clearBackgroundOutputConsumptionsForParentSession,
clearBackgroundOutputConsumptionsForTaskSession,
restoreBackgroundOutputConsumption,
} from "../shared/background-output-consumption";
import { resetMessageCursor } from "../shared";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
@@ -341,16 +346,14 @@ export function createEventHandler(args: {
firstMessageVariantGate.markSessionCreated(sessionInfo);
if (managers.resolvedMultiplexer.paneBackend === "tmux") {
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
}
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
}
if (event.type === "session.deleted") {
@@ -368,6 +371,8 @@ export function createEventHandler(args: {
clearPendingModelFallback(sessionInfo.id);
clearSessionFallbackChain(sessionInfo.id);
resetMessageCursor(sessionInfo.id);
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
clearSessionPromptParams(sessionInfo.id);
@@ -378,14 +383,18 @@ 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,
});
}
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
});
}
}
if (event.type === "message.removed") {
const messageID = props?.messageID as string | undefined;
const sessionID = props?.sessionID as string | undefined;
restoreBackgroundOutputConsumption(sessionID, messageID);
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined;
const sessionID = info?.sessionID as string | undefined;

View File

@@ -1,7 +1,6 @@
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"
@@ -13,16 +12,8 @@ export function createCoreHooks(args: {
modelCacheState: ModelCacheState
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
resolvedMultiplexer: ResolvedMultiplexer
}) {
const {
ctx,
pluginConfig,
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
} = args
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
const session = createSessionHooks({
ctx,
@@ -30,7 +21,6 @@ export function createCoreHooks(args: {
modelCacheState,
isHookEnabled,
safeHookEnabled,
resolvedMultiplexer,
})
const tool = createToolGuardHooks({

View File

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

View File

@@ -20,7 +20,7 @@ import {
createSessionManagerTools,
createDelegateTask,
discoverCommandsSync,
createInteractiveBashTool,
interactive_bash,
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, "resolvedMultiplexer" | "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
skillContext: SkillContext
availableCategories: AvailableCategory[]
}): ToolRegistryResult {
@@ -134,10 +134,6 @@ 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,
@@ -206,7 +202,7 @@ export function createToolRegistry(args: {
task: delegateTask,
skill_mcp: skillMcpTool,
skill: skillTool,
interactive_bash: createInteractiveBashTool(managers.resolvedMultiplexer),
interactive_bash,
...taskToolsRecord,
...hashlineToolsRecord,
}

View File

@@ -0,0 +1,69 @@
import { getMessageCursor, restoreMessageCursor, type CursorState } from "./session-cursor"
type MessageConsumptionKey = `${string}:${string}`
const cursorSnapshotsByMessage = new Map<MessageConsumptionKey, Map<string, CursorState | undefined>>()
function getMessageKey(sessionID: string, messageID: string): MessageConsumptionKey {
return `${sessionID}:${messageID}`
}
export function recordBackgroundOutputConsumption(
parentSessionID: string | undefined,
parentMessageID: string | undefined,
taskSessionID: string | undefined
): void {
if (!parentSessionID || !parentMessageID || !taskSessionID) return
const messageKey = getMessageKey(parentSessionID, parentMessageID)
const existing = cursorSnapshotsByMessage.get(messageKey) ?? new Map<string, CursorState | undefined>()
if (!cursorSnapshotsByMessage.has(messageKey)) {
cursorSnapshotsByMessage.set(messageKey, existing)
}
if (existing.has(taskSessionID)) return
existing.set(taskSessionID, getMessageCursor(taskSessionID))
}
export function restoreBackgroundOutputConsumption(
parentSessionID: string | undefined,
parentMessageID: string | undefined
): void {
if (!parentSessionID || !parentMessageID) return
const messageKey = getMessageKey(parentSessionID, parentMessageID)
const snapshots = cursorSnapshotsByMessage.get(messageKey)
if (!snapshots) return
cursorSnapshotsByMessage.delete(messageKey)
for (const [taskSessionID, cursor] of snapshots) {
restoreMessageCursor(taskSessionID, cursor)
}
}
export function clearBackgroundOutputConsumptionsForParentSession(sessionID: string | undefined): void {
if (!sessionID) return
const prefix = `${sessionID}:`
for (const messageKey of cursorSnapshotsByMessage.keys()) {
if (messageKey.startsWith(prefix)) {
cursorSnapshotsByMessage.delete(messageKey)
}
}
}
export function clearBackgroundOutputConsumptionsForTaskSession(taskSessionID: string | undefined): void {
if (!taskSessionID) return
for (const [messageKey, snapshots] of cursorSnapshotsByMessage) {
snapshots.delete(taskSessionID)
if (snapshots.size === 0) {
cursorSnapshotsByMessage.delete(messageKey)
}
}
}
export function clearBackgroundOutputConsumptionState(): void {
cursorSnapshotsByMessage.clear()
}

View File

@@ -13,13 +13,21 @@ export type CursorMessage = {
info?: MessageInfo
}
interface CursorState {
export interface CursorState {
lastKey?: string
lastCount: number
}
const sessionCursors = new Map<string, CursorState>()
function cloneCursorState(state: CursorState | undefined): CursorState | undefined {
if (!state) return undefined
return {
lastKey: state.lastKey,
lastCount: state.lastCount,
}
}
function buildMessageKey(message: CursorMessage, index: number): string {
const id = message.info?.id
if (id) return `id:${id}`
@@ -83,3 +91,18 @@ export function resetMessageCursor(sessionID?: string): void {
}
sessionCursors.clear()
}
export function getMessageCursor(sessionID: string | undefined): CursorState | undefined {
if (!sessionID) return undefined
return cloneCursorState(sessionCursors.get(sessionID))
}
export function restoreMessageCursor(sessionID: string | undefined, cursor: CursorState | undefined): void {
if (!sessionID) return
if (!cursor) {
sessionCursors.delete(sessionID)
return
}
sessionCursors.set(sessionID, cloneCursorState(cursor)!)
}

View File

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

View File

@@ -1,37 +1,13 @@
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(runtime?: ResolvedMultiplexer): boolean {
const resolvedRuntime = resolveRuntime(runtime)
if (resolvedRuntime) {
return resolvedRuntime.paneBackend === "tmux"
}
return isInsideTmuxEnvironment(process.env)
export function isInsideTmux(): boolean {
return isInsideTmuxEnvironment(process.env)
}
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)
export function getCurrentPaneId(): string | undefined {
return process.env.TMUX_PANE
}

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ import { formatTaskResult } from "./task-result-format"
import { formatTaskStatus } from "./task-status-format"
import { getAgentDisplayName } from "../../shared/agent-display-names"
import { recordBackgroundOutputConsumption } from "../../shared/background-output-consumption"
const SISYPHUS_JUNIOR_AGENT = getAgentDisplayName("sisyphus-junior")
type ToolContextWithMetadata = {
sessionID: string
messageID?: string
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
callID?: string
callId?: string
@@ -139,6 +141,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
}
if (resolvedTask.status === "completed") {
recordBackgroundOutputConsumption(ctx.sessionID, ctx.messageID, resolvedTask.sessionID)
return await formatTaskResult(resolvedTask, client)
}

View File

@@ -0,0 +1,129 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { BackgroundTask } from "../../features/background-agent"
import { createEventHandler } from "../../plugin/event"
import { clearBackgroundOutputConsumptionState } from "../../shared/background-output-consumption"
import { resetMessageCursor } from "../../shared/session-cursor"
import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients"
import { createBackgroundOutput } from "./create-background-output"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const parentSessionID = "parent-session"
const taskSessionID = "task-session"
type ToolContextWithCallID = ToolContext & {
callID: string
}
const baseContext = {
sessionID: parentSessionID,
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
callID: "call-1",
} as const satisfies Partial<ToolContextWithCallID>
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: taskSessionID,
parentSessionID,
parentMessageID: "msg-parent",
description: "background task",
prompt: "do work",
agent: "test-agent",
status: "completed",
...overrides,
}
}
function createMockClient(): BackgroundOutputClient {
return {
session: {
messages: async () => ({
data: [
{
id: "m1",
info: { role: "assistant", time: "2026-01-01T00:00:00Z" },
parts: [{ type: "text", text: "final result" }],
},
],
}),
},
}
}
function createMockEventHandler() {
return createEventHandler({
ctx: {} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
skillMcpManager: {
disconnectSession: async () => {},
},
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as never,
hooks: {} as never,
})
}
afterEach(() => {
resetMessageCursor(taskSessionID)
clearBackgroundOutputConsumptionState()
})
describe("createBackgroundOutput undo regression", () => {
test("#given consumed background output #when undo removes the parent message #then output can be consumed again", async () => {
// #given
const task = createTask()
const manager: BackgroundOutputManager = {
getTask: id => (id === task.id ? task : undefined),
}
const tool = createBackgroundOutput(manager, createMockClient())
const eventHandler = createMockEventHandler()
// #when
const firstOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, messageID: "msg-result-1" } as ToolContextWithCallID
)
const secondOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, callID: "call-2", messageID: "msg-result-2" } as ToolContextWithCallID
)
await eventHandler({
event: {
type: "message.removed",
properties: {
sessionID: parentSessionID,
messageID: "msg-result-1",
},
},
})
const thirdOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, callID: "call-3", messageID: "msg-result-3" } as ToolContextWithCallID
)
// #then
expect(firstOutput).toContain("final result")
expect(secondOutput).toContain("No new output since last check")
expect(thirdOutput).toContain("final result")
})
})

View File

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

View File

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

View File

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

View File

@@ -1,166 +0,0 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
classifyCmuxEndpoint,
isConnectionRefusedText,
probeCmuxNotificationCapability,
probeCmuxReachability,
probeTmuxRuntime,
resetMultiplexerPathCacheForTesting,
supportsCmuxNotifyFlagModel,
} from "./tmux-path-resolver"
describe("tmux-path-resolver probe environment", () => {
beforeEach(() => {
resetMultiplexerPathCacheForTesting()
})
test("probeTmuxRuntime resolves tmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeTmuxRuntime({
environment: {
PATH: "/tmp/custom-tmux-bin",
TMUX: "/tmp/tmux-501/default,1,0",
TMUX_PANE: "%1",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"tmux",
{ PATH: "/tmp/custom-tmux-bin" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeCmuxReachability resolves cmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeCmuxReachability({
environment: {
PATH: "/tmp/custom-cmux-bin",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"cmux",
{ PATH: "/tmp/custom-cmux-bin" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeTmuxRuntime honors explicit PATH removal in custom environment", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeTmuxRuntime({
environment: {
PATH: undefined,
TMUX: "/tmp/tmux-501/default,1,0",
TMUX_PANE: "%1",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"tmux",
{ PATH: "" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => {
if (process.platform === "win32") {
return
}
const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-"))
const fakeCmuxPath = join(tempDirectory, "cmux")
const slowCmuxScript = `#!/bin/sh
if [ "$1" = "notify" ]; then
trap '' TERM
/bin/sleep 1
exit 0
fi
exit 1
`
writeFileSync(fakeCmuxPath, slowCmuxScript)
chmodSync(fakeCmuxPath, 0o755)
try {
const startedAt = Date.now()
const probe = await probeCmuxNotificationCapability({
cmuxPath: fakeCmuxPath,
environment: {
PATH: tempDirectory,
},
timeoutMs: 40,
})
const elapsedMs = Date.now() - startedAt
expect(probe.failureKind).toBe("timeout")
expect(elapsedMs).toBeLessThan(500)
} finally {
rmSync(tempDirectory, { recursive: true, force: true })
}
})
})
describe("tmux-path-resolver cmux endpoint helpers", () => {
test("classifies relay host:port endpoint as relay", () => {
expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay")
})
test("classifies unix socket path as unix", () => {
expect(classifyCmuxEndpoint("/tmp/cmux.sock")).toBe("unix")
})
test("classifies empty endpoint as missing", () => {
expect(classifyCmuxEndpoint(" ")).toBe("missing")
})
test("detects connection refused text", () => {
expect(isConnectionRefusedText("connect: connection refused")).toBe(true)
expect(isConnectionRefusedText("ECONNREFUSED while connecting")).toBe(true)
expect(isConnectionRefusedText("permission denied")).toBe(false)
})
test("accepts cmux notify help output with title/body model", () => {
const help = `
cmux notify
Flags:
--title <text>
--body <text>
`
expect(supportsCmuxNotifyFlagModel(help)).toBe(true)
})
test("rejects legacy message-based notify flag model", () => {
const legacyHelp = `
cmux notify
Flags:
--title <text>
--message <text>
`
expect(supportsCmuxNotifyFlagModel(legacyHelp)).toBe(false)
})
})

View File

@@ -1,641 +1,71 @@
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 tmuxPathInitialized = false
let tmuxPathInitPromise: Promise<string | null> | null = null
let initPromise: Promise<string | null> | null = null
let cmuxPath: string | null = null
let cmuxPathInitialized = false
let cmuxPathInitPromise: Promise<string | null> | null = null
async function findTmuxPath(): Promise<string | null> {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
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(args, {
const proc = spawn([cmd, "tmux"], {
stdout: "pipe",
stderr: "pipe",
env: toProbeEnvironment(options.environment),
})
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 exitCode = await proc.exited
if (exitCode !== 0) {
return null
}
if (timedOut) {
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: true,
}
const stdout = await new Response(proc.stdout).text()
const path = stdout.trim().split("\n")[0]
if (!path) {
return null
}
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 verifyProc = spawn([path, "-V"], {
stdout: "pipe",
stderr: "pipe",
})
return {
exitCode,
stdout,
stderr,
timedOut,
const verifyExitCode = await verifyProc.exited
if (verifyExitCode !== 0) {
return null
}
} catch {
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: false,
}
}
}
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
return path
} 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 (tmuxPathInitialized) {
if (tmuxPath !== null) {
return tmuxPath
}
if (tmuxPathInitPromise) {
return tmuxPathInitPromise
if (initPromise) {
return initPromise
}
tmuxPathInitPromise = (async () => {
initPromise = (async () => {
const path = await findTmuxPath()
tmuxPath = path
tmuxPathInitialized = true
return path
})()
return tmuxPathInitPromise
return initPromise
}
export function getCachedTmuxPath(): string | null {
return tmuxPath
}
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(() => {})
if (!initPromise) {
initPromise = getTmuxPath()
initPromise.catch(() => {})
}
}
export function resetMultiplexerPathCacheForTesting(): void {
tmuxPath = null
tmuxPathInitialized = false
tmuxPathInitPromise = null
cmuxPath = null
cmuxPathInitialized = false
cmuxPathInitPromise = null
}

View File

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

View File

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