Compare commits
5 Commits
feat/cmux-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc11ea620 | ||
|
|
5a28ee1bef | ||
|
|
5d4e57ce96 | ||
|
|
b2497f1327 | ||
|
|
9fc56ab544 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
28
src/features/claude-code-mcp-loader/scope-filter.ts
Normal file
28
src/features/claude-code-mcp-loader/scope-filter.ts
Normal 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)
|
||||
}
|
||||
82
src/features/claude-code-mcp-loader/scope-filtering.test.ts
Normal file
82
src/features/claude-code-mcp-loader/scope-filtering.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
29
src/features/claude-code-mcp-loader/transformer.test.ts
Normal file
29
src/features/claude-code-mcp-loader/transformer.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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." }
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
18
src/index.ts
18
src/index.ts
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
69
src/shared/background-output-consumption.ts
Normal file
69
src/shared/background-output-consumption.ts
Normal 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()
|
||||
}
|
||||
@@ -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)!)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
129
src/tools/background-task/create-background-output.undo.test.ts
Normal file
129
src/tools/background-task/create-background-output.undo.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user