Browse Source

feat: add zellij multiplexer support with auto-detection (#235)

* feat: add zellij multiplexer support with auto-detection

- Extract tmux into generic multiplexer abstraction
- Add zellij multiplexer with dedicated tab support
- Implement auto-detection for tmux/zellij based on environment
- Reuse default pane from new-tab for first sub-agent
- Add multiplexer config schema with 'auto', 'tmux', 'zellij', 'none' types
- Update docs for multiplexer integration (tmux + zellij)
- Maintain backward compatibility with legacy tmux config
- Add JSON-based tab/pane management for zellij

Fixes from code review:
- Forward layout/main_pane_size config to both multiplexers
- Fix cache key to use actualType instead of 'auto'
- Fix getBinary() to properly cache via isAvailable()
- Fix tmuxEnabled logic for type:'auto' outside session
- Fix firstPaneUsed to only set on success
- Fix getFirstPaneInTab to restore user's original tab
- Fix pane ID validation to be consistent (startsWith 'terminal_')
- Fix graceful shutdown delay to 250ms (matches tmux)
- Remove dead _detectMultiplexer function
- Add clear comments about zellij layout behavior differences

* docs: fix multiplexer doc references

---------

Co-authored-by: Alvin Unreal <alvin@cmngoal.com>
Drin 1 week ago
parent
commit
3d2375a3e0

+ 2 - 2
README.md

@@ -26,7 +26,7 @@ The installer generates an OpenAI configuration by default (using `gpt-5.4` and
 For non-interactive mode:
 For non-interactive mode:
 
 
 ```bash
 ```bash
-bunx oh-my-opencode-slim@latest install --no-tui --tmux=no --skills=yes
+bunx oh-my-opencode-slim@latest install --no-tui --tmux=yes --skills=yes
 ```
 ```
 
 
 To force overwrite of an existing configuration:
 To force overwrite of an existing configuration:
@@ -358,7 +358,7 @@ If any agent fails to respond, check your provider authentication and config fil
 | Feature | Doc | What it does |
 | Feature | Doc | What it does |
 |---------|-----|--------------|
 |---------|-----|--------------|
 | **Council** | [council.md](docs/council.md) | Run N models in parallel, synthesize one answer (`@council`) |
 | **Council** | [council.md](docs/council.md) | Run N models in parallel, synthesize one answer (`@council`) |
-| **Tmux Integration** | [tmux-integration.md](docs/tmux-integration.md) | Watch agents work in real-time with auto-spawned panes |
+| **Multiplexer Integration** | [multiplexer-integration.md](docs/multiplexer-integration.md) | Watch agents work in real-time with auto-spawned panes (Tmux/Zellij) |
 | **Cartography Skill** | [cartography.md](docs/cartography.md) | Auto-generate hierarchical codemaps for any codebase |
 | **Cartography Skill** | [cartography.md](docs/cartography.md) | Auto-generate hierarchical codemaps for any codebase |
 
 
 ### ⚙️ Config & Reference
 ### ⚙️ Config & Reference

+ 2 - 2
docs/authors-preset.md

@@ -18,8 +18,8 @@ This is the exact configuration the author runs day-to-day. It mixes three provi
       "fixer": { "model": "fireworks-ai/accounts/fireworks/routers/kimi-k2p5-turbo", "variant": "low", "skills": [], "mcps": [] }
       "fixer": { "model": "fireworks-ai/accounts/fireworks/routers/kimi-k2p5-turbo", "variant": "low", "skills": [], "mcps": [] }
     }
     }
   },
   },
-  "tmux": {
-    "enabled": true,
+  "multiplexer": {
+    "type": "auto",
     "layout": "main-vertical",
     "layout": "main-vertical",
     "main_pane_size": 60
     "main_pane_size": 60
   },
   },

+ 1 - 1
docs/installation.md

@@ -226,7 +226,7 @@ export OPENCODE_PORT=4096
 opencode --port 4096
 opencode --port 4096
 ```
 ```
 
 
-See the [Tmux Integration Guide](tmux-integration.md) for more details.
+See the [Multiplexer Integration Guide](multiplexer-integration.md) for more details.
 
 
 ---
 ---
 
 

+ 74 - 17
docs/tmux-integration.md

@@ -1,10 +1,11 @@
-# Tmux Integration Guide
+# Multiplexer Integration Guide
 
 
-Complete guide for using tmux integration with oh-my-opencode-slim to watch agents work in real-time through automatic pane spawning.
+Complete guide for using terminal multiplexer integration (Tmux or Zellij) with oh-my-opencode-slim to watch agents work in real-time through automatic pane spawning.
 
 
 ## Table of Contents
 ## Table of Contents
 
 
 - [Overview](#overview)
 - [Overview](#overview)
+- [Supported Multiplexers](#supported-multiplexers)
 - [Quick Setup](#quick-setup)
 - [Quick Setup](#quick-setup)
 - [Configuration](#configuration)
 - [Configuration](#configuration)
 - [Layout Options](#layout-options)
 - [Layout Options](#layout-options)
@@ -16,7 +17,7 @@ Complete guide for using tmux integration with oh-my-opencode-slim to watch agen
 
 
 ## Overview
 ## Overview
 
 
-**Watch your agents work in real-time.** When the Orchestrator launches sub-agents or initiates background tasks, new tmux panes automatically spawn showing each agent's live progress. No more waiting in the dark.
+**Watch your agents work in real-time.** When the Orchestrator launches sub-agents or initiates background tasks, new panes automatically spawn in a dedicated tab showing each agent's live progress. No more waiting in the dark.
 
 
 ### Key Benefits
 ### Key Benefits
 
 
@@ -24,30 +25,61 @@ Complete guide for using tmux integration with oh-my-opencode-slim to watch agen
 - **Automatic pane management** - panes spawn and organize automatically
 - **Automatic pane management** - panes spawn and organize automatically
 - **Interactive debugging** - you can jump into any agent's session
 - **Interactive debugging** - you can jump into any agent's session
 - **Background task monitoring** - see long-running work as it happens
 - **Background task monitoring** - see long-running work as it happens
-- **Multi-session support** - different projects can have separate tmux environments
+- **Multi-session support** - different projects can have separate environments
 
 
-> ⚠️ **Temporary workaround:** Start OpenCode with `--port` to enable tmux integration. The port must match the `OPENCODE_PORT` environment variable (default: 4096). This is required until the upstream issue is resolved. [opencode#9099](https://github.com/anomalyco/opencode/issues/9099).
+> ⚠️ **Temporary workaround:** Start OpenCode with `--port` to enable multiplexer integration. The port must match the `OPENCODE_PORT` environment variable (default: 4096). This is required until the upstream issue is resolved. [opencode#9099](https://github.com/anomalyco/opencode/issues/9099).
+
+---
+
+## Supported Multiplexers
+
+| Multiplexer | Status | Notes |
+|-------------|--------|-------|
+| **Tmux** | ✅ Supported | Full layout control with `main-vertical`, `main-horizontal`, `tiled`, etc. |
+| **Zellij** | ✅ Supported | Creates dedicated "opencode-agents" tab, reuses default pane |
 
 
 ---
 ---
 
 
 ## Quick Setup
 ## Quick Setup
 
 
-### Step 1: Enable Tmux Integration
+### Step 1: Enable Multiplexer Integration
 
 
 Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 
 
+**For Tmux:**
 ```json
 ```json
 {
 {
-  "tmux": {
-    "enabled": true,
+  "multiplexer": {
+    "type": "tmux",
     "layout": "main-vertical",
     "layout": "main-vertical",
     "main_pane_size": 60
     "main_pane_size": 60
   }
   }
 }
 }
 ```
 ```
 
 
-### Step 2: Run OpenCode Inside Tmux
+**For Zellij:**
+```json
+{
+  "multiplexer": {
+    "type": "zellij"
+  }
+}
+```
 
 
+**Auto-detect (recommended):**
+```json
+{
+  "multiplexer": {
+    "type": "auto",
+    "layout": "main-vertical",
+    "main_pane_size": 60
+  }
+}
+```
+
+### Step 2: Run OpenCode Inside Your Multiplexer
+
+**Tmux:**
 ```bash
 ```bash
 # Start a new tmux session
 # Start a new tmux session
 tmux
 tmux
@@ -56,20 +88,29 @@ tmux
 opencode --port 4096
 opencode --port 4096
 ```
 ```
 
 
+**Zellij:**
+```bash
+# Start a new zellij session
+zellij
+
+# Start OpenCode with the default port (4096)
+opencode --port 4096
+```
+
 That's it! Your agents will now spawn panes automatically.
 That's it! Your agents will now spawn panes automatically.
 
 
 ---
 ---
 
 
 ## Configuration
 ## Configuration
 
 
-### Tmux Settings
+### Multiplexer Settings
 
 
-Configure tmux behavior in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
+Configure multiplexer behavior in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 
 
 ```json
 ```json
 {
 {
-  "tmux": {
-    "enabled": true,
+  "multiplexer": {
+    "type": "auto",
     "layout": "main-vertical",
     "layout": "main-vertical",
     "main_pane_size": 60
     "main_pane_size": 60
   }
   }
@@ -78,11 +119,27 @@ Configure tmux behavior in `~/.config/opencode/oh-my-opencode-slim.json` (or `.j
 
 
 | Setting | Type | Default | Description |
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
 |---------|------|---------|-------------|
-| `enabled` | boolean | `false` | Enable/disable tmux pane spawning |
-| `layout` | string | `"main-vertical"` | Layout preset (see [Layout Options](#layout-options)) |
-| `main_pane_size` | number | `60` | Main pane size as percentage (20-80) |
+| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, or `"none"` |
+| `layout` | string | `"main-vertical"` | Layout preset (tmux only, see [Layout Options](#layout-options)) |
+| `main_pane_size` | number | `60` | Main pane size as percentage (tmux only, 20-80) |
+
+### Legacy Tmux Config (still supported)
 
 
-### Layout Options
+```json
+{
+  "tmux": {
+    "enabled": true,
+    "layout": "main-vertical",
+    "main_pane_size": 60
+  }
+}
+```
+
+This is automatically converted to `multiplexer.type: "tmux"`.
+
+### Layout Options (Tmux only)
+
+Choose how panes are arranged:
 
 
 Choose how panes are arranged:
 Choose how panes are arranged:
 
 

+ 1 - 1
docs/quick-reference.md

@@ -14,7 +14,7 @@
 | Doc | Contents |
 | Doc | Contents |
 |-----|----------|
 |-----|----------|
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
-| [Tmux Integration](tmux-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
+| [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Cartography Skill](cartography.md) | Hierarchical codemap generation |
 | [Cartography Skill](cartography.md) | Hierarchical codemap generation |
 
 
 ## ⚙️ Config & Reference
 ## ⚙️ Config & Reference

+ 1 - 1
docs/tools.md

@@ -14,7 +14,7 @@ Launch agents asynchronously and collect results later. This is how the Orchestr
 | `background_output` | Fetch the result of a background task by ID |
 | `background_output` | Fetch the result of a background task by ID |
 | `background_cancel` | Abort a running background task |
 | `background_cancel` | Abort a running background task |
 
 
-Background tasks integrate with [Tmux Integration](tmux-integration.md) — when tmux is enabled, each background task spawns a pane so you can watch it live.
+Background tasks integrate with [Multiplexer Integration](multiplexer-integration.md) — when multiplexer support is enabled, each background task spawns a pane so you can watch it live.
 
 
 ---
 ---
 
 

+ 45 - 0
oh-my-opencode-slim.schema.json

@@ -331,6 +331,38 @@
         "type": "string"
         "type": "string"
       }
       }
     },
     },
+    "multiplexer": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "default": "none",
+          "type": "string",
+          "enum": [
+            "auto",
+            "tmux",
+            "zellij",
+            "none"
+          ]
+        },
+        "layout": {
+          "default": "main-vertical",
+          "type": "string",
+          "enum": [
+            "main-horizontal",
+            "main-vertical",
+            "tiled",
+            "even-horizontal",
+            "even-vertical"
+          ]
+        },
+        "main_pane_size": {
+          "default": 60,
+          "type": "number",
+          "minimum": 20,
+          "maximum": 80
+        }
+      }
+    },
     "tmux": {
     "tmux": {
       "type": "object",
       "type": "object",
       "properties": {
       "properties": {
@@ -357,6 +389,19 @@
         }
         }
       }
       }
     },
     },
+    "websearch": {
+      "type": "object",
+      "properties": {
+        "provider": {
+          "default": "exa",
+          "type": "string",
+          "enum": [
+            "exa",
+            "tavily"
+          ]
+        }
+      }
+    },
     "background": {
     "background": {
       "type": "object",
       "type": "object",
       "properties": {
       "properties": {

+ 9 - 3
src/background/background-manager.ts

@@ -19,7 +19,8 @@ import {
   FALLBACK_FAILOVER_TIMEOUT_MS,
   FALLBACK_FAILOVER_TIMEOUT_MS,
   SUBAGENT_DELEGATION_RULES,
   SUBAGENT_DELEGATION_RULES,
 } from '../config';
 } from '../config';
-import type { TmuxConfig } from '../config/schema';
+import type { MultiplexerConfig } from '../config/schema';
+import { getMultiplexer } from '../multiplexer';
 import {
 import {
   applyAgentVariant,
   applyAgentVariant,
   createInternalAgentTextPart,
   createInternalAgentTextPart,
@@ -100,12 +101,17 @@ export class BackgroundTaskManager {
 
 
   constructor(
   constructor(
     ctx: PluginInput,
     ctx: PluginInput,
-    tmuxConfig?: TmuxConfig,
+    multiplexerConfig?: MultiplexerConfig,
     config?: PluginConfig,
     config?: PluginConfig,
   ) {
   ) {
     this.client = ctx.client;
     this.client = ctx.client;
     this.directory = ctx.directory;
     this.directory = ctx.directory;
-    this.tmuxEnabled = tmuxConfig?.enabled ?? false;
+    // Check if multiplexer is actually available (handles 'auto' type correctly)
+    this.tmuxEnabled =
+      multiplexerConfig !== undefined &&
+      multiplexerConfig.type !== 'none' &&
+      multiplexerConfig.type !== undefined &&
+      getMultiplexer(multiplexerConfig) !== null;
     this.config = config;
     this.config = config;
     this.backgroundConfig = config?.background ?? {
     this.backgroundConfig = config?.background ?? {
       maxConcurrentStarts: 10,
       maxConcurrentStarts: 10,

+ 4 - 1
src/background/index.ts

@@ -3,5 +3,8 @@ export {
   BackgroundTaskManager,
   BackgroundTaskManager,
   type LaunchOptions,
   type LaunchOptions,
 } from './background-manager';
 } from './background-manager';
+export {
+  MultiplexerSessionManager,
+  TmuxSessionManager,
+} from './multiplexer-session-manager';
 export { SubagentDepthTracker } from './subagent-depth';
 export { SubagentDepthTracker } from './subagent-depth';
-export { TmuxSessionManager } from './tmux-session-manager';

+ 78 - 41
src/background/tmux-session-manager.test.ts

@@ -1,19 +1,24 @@
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
-import { TmuxSessionManager } from './tmux-session-manager';
+import { MultiplexerSessionManager } from './multiplexer-session-manager';
+
+// Define the mock multiplexer
+const mockMultiplexer = {
+  type: 'tmux' as const,
+  isAvailable: mock(async () => true),
+  isInsideSession: mock(() => true),
+  spawnPane: mock(async () => ({
+    success: true,
+    paneId: '%mock-pane',
+  })),
+  closePane: mock(async () => true),
+  applyLayout: mock(async () => {}),
+};
 
 
-// Define the mock outside so we can access it
-const mockSpawnTmuxPane = mock(async () => ({
-  success: true,
-  paneId: '%mock-pane',
-}));
-const mockCloseTmuxPane = mock(async () => true);
-const mockIsInsideTmux = mock(() => true);
-
-// Mock the tmux utils module
-mock.module('../utils/tmux', () => ({
-  spawnTmuxPane: mockSpawnTmuxPane,
-  closeTmuxPane: mockCloseTmuxPane,
-  isInsideTmux: mockIsInsideTmux,
+// Mock the multiplexer module
+mock.module('../multiplexer', () => ({
+  getMultiplexer: () => mockMultiplexer,
+  isServerRunning: mock(async () => true),
+  startAvailabilityCheck: () => {},
 }));
 }));
 
 
 // Mock the plugin context
 // Mock the plugin context
@@ -33,24 +38,27 @@ function createMockContext(overrides?: {
   } as any;
   } as any;
 }
 }
 
 
-const defaultTmuxConfig = {
-  enabled: true,
+const defaultMultiplexerConfig = {
+  type: 'tmux' as const,
   layout: 'main-vertical' as const,
   layout: 'main-vertical' as const,
   main_pane_size: 60,
   main_pane_size: 60,
 };
 };
 
 
-describe('TmuxSessionManager', () => {
+describe('MultiplexerSessionManager', () => {
   beforeEach(() => {
   beforeEach(() => {
-    mockSpawnTmuxPane.mockClear();
-    mockCloseTmuxPane.mockClear();
-    mockIsInsideTmux.mockClear();
-    mockIsInsideTmux.mockReturnValue(true);
+    mockMultiplexer.spawnPane.mockClear();
+    mockMultiplexer.closePane.mockClear();
+    mockMultiplexer.isInsideSession.mockClear();
+    mockMultiplexer.isInsideSession.mockReturnValue(true);
   });
   });
 
 
   describe('constructor', () => {
   describe('constructor', () => {
     test('initializes with config', () => {
     test('initializes with config', () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
       expect(manager).toBeDefined();
       expect(manager).toBeDefined();
     });
     });
   });
   });
@@ -58,7 +66,10 @@ describe('TmuxSessionManager', () => {
   describe('onSessionCreated', () => {
   describe('onSessionCreated', () => {
     test('spawns pane for child sessions', async () => {
     test('spawns pane for child sessions', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
 
 
       await manager.onSessionCreated({
       await manager.onSessionCreated({
         type: 'session.created',
         type: 'session.created',
@@ -71,12 +82,15 @@ describe('TmuxSessionManager', () => {
         },
         },
       });
       });
 
 
-      expect(mockSpawnTmuxPane).toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalled();
     });
     });
 
 
     test('ignores sessions without parentID', async () => {
     test('ignores sessions without parentID', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
 
 
       await manager.onSessionCreated({
       await manager.onSessionCreated({
         type: 'session.created',
         type: 'session.created',
@@ -88,14 +102,14 @@ describe('TmuxSessionManager', () => {
         },
         },
       });
       });
 
 
-      expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
     });
     });
 
 
     test('ignores if disabled in config', async () => {
     test('ignores if disabled in config', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      const manager = new TmuxSessionManager(ctx, {
-        ...defaultTmuxConfig,
-        enabled: false,
+      const manager = new MultiplexerSessionManager(ctx, {
+        ...defaultMultiplexerConfig,
+        type: 'none',
       });
       });
 
 
       await manager.onSessionCreated({
       await manager.onSessionCreated({
@@ -105,16 +119,22 @@ describe('TmuxSessionManager', () => {
         },
         },
       });
       });
 
 
-      expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
     });
     });
   });
   });
 
 
   describe('polling and closure', () => {
   describe('polling and closure', () => {
     test('closes pane when session becomes idle', async () => {
     test('closes pane when session becomes idle', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      mockSpawnTmuxPane.mockResolvedValue({ success: true, paneId: 'p-1' });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-1',
+      });
 
 
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
 
 
       // Register session
       // Register session
       await manager.onSessionCreated({
       await manager.onSessionCreated({
@@ -129,12 +149,15 @@ describe('TmuxSessionManager', () => {
 
 
       await (manager as any).pollSessions();
       await (manager as any).pollSessions();
 
 
-      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p-1');
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
     });
     });
 
 
     test('does not close on transient status absence', async () => {
     test('does not close on transient status absence', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
 
 
       await manager.onSessionCreated({
       await manager.onSessionCreated({
         type: 'session.created',
         type: 'session.created',
@@ -144,17 +167,21 @@ describe('TmuxSessionManager', () => {
       ctx.client.session.status.mockResolvedValue({ data: {} });
       ctx.client.session.status.mockResolvedValue({ data: {} });
       await (manager as any).pollSessions();
       await (manager as any).pollSessions();
 
 
-      expect(mockCloseTmuxPane).not.toHaveBeenCalled();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
     });
   });
   });
 
 
   describe('cleanup', () => {
   describe('cleanup', () => {
     test('closes all tracked panes concurrently', async () => {
     test('closes all tracked panes concurrently', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
-      mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: 'p1' });
-      mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: 'p2' });
+      mockMultiplexer.spawnPane
+        .mockResolvedValueOnce({ success: true, paneId: 'p1' })
+        .mockResolvedValueOnce({ success: true, paneId: 'p2' });
 
 
-      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
 
 
       await manager.onSessionCreated({
       await manager.onSessionCreated({
         type: 'session.created',
         type: 'session.created',
@@ -167,9 +194,19 @@ describe('TmuxSessionManager', () => {
 
 
       await manager.cleanup();
       await manager.cleanup();
 
 
-      expect(mockCloseTmuxPane).toHaveBeenCalledTimes(2);
-      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p1');
-      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p2');
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(2);
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p1');
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p2');
     });
     });
   });
   });
 });
 });
+
+// Backward compatibility test
+describe('TmuxSessionManager (backward compatibility)', () => {
+  test('TmuxSessionManager is alias for MultiplexerSessionManager', async () => {
+    const { TmuxSessionManager } = await import(
+      './multiplexer-session-manager'
+    );
+    expect(TmuxSessionManager).toBe(MultiplexerSessionManager);
+  });
+});

+ 64 - 38
src/background/tmux-session-manager.ts

@@ -1,8 +1,12 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import { POLL_INTERVAL_BACKGROUND_MS } from '../config';
 import { POLL_INTERVAL_BACKGROUND_MS } from '../config';
-import type { TmuxConfig } from '../config/schema';
+import type { MultiplexerConfig } from '../config/schema';
+import {
+  getMultiplexer,
+  isServerRunning,
+  type Multiplexer,
+} from '../multiplexer';
 import { log } from '../utils/logger';
 import { log } from '../utils/logger';
-import { closeTmuxPane, isInsideTmux, spawnTmuxPane } from '../utils/tmux';
 
 
 type OpencodeClient = PluginInput['client'];
 type OpencodeClient = PluginInput['client'];
 
 
@@ -32,39 +36,47 @@ const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
 const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 
 
 /**
 /**
- * TmuxSessionManager tracks child sessions and spawns/closes tmux panes for them.
+ * MultiplexerSessionManager tracks child sessions and spawns/closes multiplexer panes for them.
  *
  *
  * Uses session.status events for completion detection instead of polling.
  * Uses session.status events for completion detection instead of polling.
+ * Supports both tmux and zellij multiplexers.
  */
  */
-export class TmuxSessionManager {
+export class MultiplexerSessionManager {
   private client: OpencodeClient;
   private client: OpencodeClient;
-  private tmuxConfig: TmuxConfig;
   private serverUrl: string;
   private serverUrl: string;
+  private multiplexer: Multiplexer | null = null;
   private sessions = new Map<string, TrackedSession>();
   private sessions = new Map<string, TrackedSession>();
   private pollInterval?: ReturnType<typeof setInterval>;
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
   private enabled = false;
 
 
-  constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
+  constructor(ctx: PluginInput, config: MultiplexerConfig) {
     this.client = ctx.client;
     this.client = ctx.client;
-    this.tmuxConfig = tmuxConfig;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
     this.serverUrl =
     this.serverUrl =
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
-    this.enabled = tmuxConfig.enabled && isInsideTmux();
 
 
-    log('[tmux-session-manager] initialized', {
+    // Get the multiplexer instance
+    this.multiplexer = getMultiplexer(config);
+
+    // Enable only if a multiplexer is configured and we're inside a session
+    this.enabled =
+      config.type !== 'none' &&
+      this.multiplexer !== null &&
+      this.multiplexer.isInsideSession();
+
+    log('[multiplexer-session-manager] initialized', {
       enabled: this.enabled,
       enabled: this.enabled,
-      tmuxConfig: this.tmuxConfig,
+      type: config.type,
       serverUrl: this.serverUrl,
       serverUrl: this.serverUrl,
     });
     });
   }
   }
 
 
   /**
   /**
    * Handle session.created events.
    * Handle session.created events.
-   * Spawns a tmux pane for child sessions (those with parentID).
+   * Spawns a multiplexer pane for child sessions (those with parentID).
    */
    */
   async onSessionCreated(event: SessionEvent): Promise<void> {
   async onSessionCreated(event: SessionEvent): Promise<void> {
-    if (!this.enabled) return;
+    if (!this.enabled || !this.multiplexer) return;
     if (event.type !== 'session.created') return;
     if (event.type !== 'session.created') return;
 
 
     const info = event.properties?.info;
     const info = event.properties?.info;
@@ -79,27 +91,35 @@ export class TmuxSessionManager {
 
 
     // Skip if we're already tracking this session
     // Skip if we're already tracking this session
     if (this.sessions.has(sessionId)) {
     if (this.sessions.has(sessionId)) {
-      log('[tmux-session-manager] session already tracked', { sessionId });
+      log('[multiplexer-session-manager] session already tracked', {
+        sessionId,
+      });
       return;
       return;
     }
     }
 
 
-    log('[tmux-session-manager] child session created, spawning pane', {
+    // Check server is running before spawning
+    const serverRunning = await isServerRunning(this.serverUrl);
+    if (!serverRunning) {
+      log('[multiplexer-session-manager] server not running, skipping', {
+        serverUrl: this.serverUrl,
+      });
+      return;
+    }
+
+    log('[multiplexer-session-manager] child session created, spawning pane', {
       sessionId,
       sessionId,
       parentId,
       parentId,
       title,
       title,
     });
     });
 
 
-    const paneResult = await spawnTmuxPane(
-      sessionId,
-      title,
-      this.tmuxConfig,
-      this.serverUrl,
-    ).catch((err) => {
-      log('[tmux-session-manager] failed to spawn pane', {
-        error: String(err),
+    const paneResult = await this.multiplexer
+      .spawnPane(sessionId, title, this.serverUrl)
+      .catch((err) => {
+        log('[multiplexer-session-manager] failed to spawn pane', {
+          error: String(err),
+        });
+        return { success: false, paneId: undefined };
       });
       });
-      return { success: false, paneId: undefined };
-    });
 
 
     if (paneResult.success && paneResult.paneId) {
     if (paneResult.success && paneResult.paneId) {
       const now = Date.now();
       const now = Date.now();
@@ -112,7 +132,7 @@ export class TmuxSessionManager {
         lastSeenAt: now,
         lastSeenAt: now,
       });
       });
 
 
-      log('[tmux-session-manager] pane spawned', {
+      log('[multiplexer-session-manager] pane spawned', {
         sessionId,
         sessionId,
         paneId: paneResult.paneId,
         paneId: paneResult.paneId,
       });
       });
@@ -143,7 +163,7 @@ export class TmuxSessionManager {
 
 
   /**
   /**
    * Handle session.deleted events.
    * Handle session.deleted events.
-   * When a session is deleted, close its tmux pane immediately.
+   * When a session is deleted, close its multiplexer pane immediately.
    */
    */
   async onSessionDeleted(event: SessionEvent): Promise<void> {
   async onSessionDeleted(event: SessionEvent): Promise<void> {
     if (!this.enabled) return;
     if (!this.enabled) return;
@@ -152,7 +172,7 @@ export class TmuxSessionManager {
     const sessionId = event.properties?.sessionID;
     const sessionId = event.properties?.sessionID;
     if (!sessionId) return;
     if (!sessionId) return;
 
 
-    log('[tmux-session-manager] session deleted, closing pane', {
+    log('[multiplexer-session-manager] session deleted, closing pane', {
       sessionId,
       sessionId,
     });
     });
 
 
@@ -166,14 +186,14 @@ export class TmuxSessionManager {
       () => this.pollSessions(),
       () => this.pollSessions(),
       POLL_INTERVAL_BACKGROUND_MS,
       POLL_INTERVAL_BACKGROUND_MS,
     );
     );
-    log('[tmux-session-manager] polling started');
+    log('[multiplexer-session-manager] polling started');
   }
   }
 
 
   private stopPolling(): void {
   private stopPolling(): void {
     if (this.pollInterval) {
     if (this.pollInterval) {
       clearInterval(this.pollInterval);
       clearInterval(this.pollInterval);
       this.pollInterval = undefined;
       this.pollInterval = undefined;
-      log('[tmux-session-manager] polling stopped');
+      log('[multiplexer-session-manager] polling stopped');
     }
     }
   }
   }
 
 
@@ -226,20 +246,20 @@ export class TmuxSessionManager {
         await this.closeSession(sessionId);
         await this.closeSession(sessionId);
       }
       }
     } catch (err) {
     } catch (err) {
-      log('[tmux-session-manager] poll error', { error: String(err) });
+      log('[multiplexer-session-manager] poll error', { error: String(err) });
     }
     }
   }
   }
 
 
   private async closeSession(sessionId: string): Promise<void> {
   private async closeSession(sessionId: string): Promise<void> {
     const tracked = this.sessions.get(sessionId);
     const tracked = this.sessions.get(sessionId);
-    if (!tracked) return;
+    if (!tracked || !this.multiplexer) return;
 
 
-    log('[tmux-session-manager] closing session pane', {
+    log('[multiplexer-session-manager] closing session pane', {
       sessionId,
       sessionId,
       paneId: tracked.paneId,
       paneId: tracked.paneId,
     });
     });
 
 
-    await closeTmuxPane(tracked.paneId);
+    await this.multiplexer.closePane(tracked.paneId);
     this.sessions.delete(sessionId);
     this.sessions.delete(sessionId);
 
 
     if (this.sessions.size === 0) {
     if (this.sessions.size === 0) {
@@ -253,13 +273,14 @@ export class TmuxSessionManager {
   async cleanup(): Promise<void> {
   async cleanup(): Promise<void> {
     this.stopPolling();
     this.stopPolling();
 
 
-    if (this.sessions.size > 0) {
-      log('[tmux-session-manager] closing all panes', {
+    if (this.sessions.size > 0 && this.multiplexer) {
+      log('[multiplexer-session-manager] closing all panes', {
         count: this.sessions.size,
         count: this.sessions.size,
       });
       });
+      const multiplexer = this.multiplexer;
       const closePromises = Array.from(this.sessions.values()).map((s) =>
       const closePromises = Array.from(this.sessions.values()).map((s) =>
-        closeTmuxPane(s.paneId).catch((err) =>
-          log('[tmux-session-manager] cleanup error for pane', {
+        multiplexer.closePane(s.paneId).catch((err) =>
+          log('[multiplexer-session-manager] cleanup error for pane', {
             paneId: s.paneId,
             paneId: s.paneId,
             error: String(err),
             error: String(err),
           }),
           }),
@@ -269,6 +290,11 @@ export class TmuxSessionManager {
       this.sessions.clear();
       this.sessions.clear();
     }
     }
 
 
-    log('[tmux-session-manager] cleanup complete');
+    log('[multiplexer-session-manager] cleanup complete');
   }
   }
 }
 }
+
+/**
+ * @deprecated Use MultiplexerSessionManager instead
+ */
+export const TmuxSessionManager = MultiplexerSessionManager;

+ 35 - 1
src/config/loader.ts

@@ -1,7 +1,7 @@
 import * as fs from 'node:fs';
 import * as fs from 'node:fs';
 import * as path from 'node:path';
 import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
 import { stripJsonComments } from '../cli/config-io';
-import { getConfigDir, getConfigSearchDirs } from '../cli/paths';
+import { getConfigSearchDirs } from '../cli/paths';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 
 
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
@@ -162,10 +162,15 @@ export function loadPluginConfig(directory: string): PluginConfig {
       ...projectConfig,
       ...projectConfig,
       agents: deepMerge(config.agents, projectConfig.agents),
       agents: deepMerge(config.agents, projectConfig.agents),
       tmux: deepMerge(config.tmux, projectConfig.tmux),
       tmux: deepMerge(config.tmux, projectConfig.tmux),
+      multiplexer: deepMerge(config.multiplexer, projectConfig.multiplexer),
       fallback: deepMerge(config.fallback, projectConfig.fallback),
       fallback: deepMerge(config.fallback, projectConfig.fallback),
+      council: deepMerge(config.council, projectConfig.council),
     };
     };
   }
   }
 
 
+  // Migrate legacy tmux config to multiplexer config for backward compatibility
+  config = migrateTmuxToMultiplexer(config);
+
   // Override preset from environment variable if set
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
   if (envPreset) {
@@ -258,3 +263,32 @@ export function loadAgentPrompt(
 
 
   return result;
   return result;
 }
 }
+
+/**
+ * Migrate legacy tmux config to multiplexer config for backward compatibility.
+ * If tmux.enabled is true and no multiplexer config is set, creates a multiplexer
+ * config from the tmux settings.
+ *
+ * @param config - Plugin config to migrate
+ * @returns Config with multiplexer settings applied
+ */
+function migrateTmuxToMultiplexer(config: PluginConfig): PluginConfig {
+  // If multiplexer is already configured, use it as-is
+  if (config.multiplexer?.type && config.multiplexer.type !== 'none') {
+    return config;
+  }
+
+  // If tmux is enabled, migrate to multiplexer
+  if (config.tmux?.enabled) {
+    return {
+      ...config,
+      multiplexer: {
+        type: 'tmux',
+        layout: config.tmux.layout ?? 'main-vertical',
+        main_pane_size: config.tmux.main_pane_size ?? 60,
+      },
+    };
+  }
+
+  return config;
+}

+ 27 - 5
src/config/schema.ts

@@ -100,8 +100,12 @@ export const AgentOverrideConfigSchema = z.object({
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
 });
 });
 
 
-// Tmux layout options
-export const TmuxLayoutSchema = z.enum([
+// Multiplexer type options
+export const MultiplexerTypeSchema = z.enum(['auto', 'tmux', 'zellij', 'none']);
+export type MultiplexerType = z.infer<typeof MultiplexerTypeSchema>;
+
+// Layout options (shared across multiplexers)
+export const MultiplexerLayoutSchema = z.enum([
   'main-horizontal', // Main pane on top, agents stacked below
   'main-horizontal', // Main pane on top, agents stacked below
   'main-vertical', // Main pane on left, agents stacked on right
   'main-vertical', // Main pane on left, agents stacked on right
   'tiled', // All panes equal size grid
   'tiled', // All panes equal size grid
@@ -109,9 +113,23 @@ export const TmuxLayoutSchema = z.enum([
   'even-vertical', // All panes stacked vertically
   'even-vertical', // All panes stacked vertically
 ]);
 ]);
 
 
-export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>;
+export type MultiplexerLayout = z.infer<typeof MultiplexerLayoutSchema>;
+
+// Legacy Tmux layout options (for backward compatibility)
+export const TmuxLayoutSchema = MultiplexerLayoutSchema;
+export type TmuxLayout = MultiplexerLayout;
+
+// Multiplexer integration configuration (new unified config)
+export const MultiplexerConfigSchema = z.object({
+  type: MultiplexerTypeSchema.default('none'),
+  layout: MultiplexerLayoutSchema.default('main-vertical'),
+  main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
+});
+
+export type MultiplexerConfig = z.infer<typeof MultiplexerConfigSchema>;
 
 
-// Tmux integration configuration
+// Legacy Tmux integration configuration (for backward compatibility)
+// When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
 export const TmuxConfigSchema = z.object({
 export const TmuxConfigSchema = z.object({
   enabled: z.boolean().default(false),
   enabled: z.boolean().default(false),
   layout: TmuxLayoutSchema.default('main-vertical'),
   layout: TmuxLayoutSchema.default('main-vertical'),
@@ -172,8 +190,12 @@ export const PluginConfigSchema = z.object({
   presets: z.record(z.string(), PresetSchema).optional(),
   presets: z.record(z.string(), PresetSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   disabled_mcps: z.array(z.string()).optional(),
   disabled_mcps: z.array(z.string()).optional(),
-  websearch: WebsearchConfigSchema.optional(),
+  // Multiplexer config (new unified config - preferred)
+  multiplexer: MultiplexerConfigSchema.optional(),
+  // Legacy tmux config (for backward compatibility)
+  // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
   tmux: TmuxConfigSchema.optional(),
   tmux: TmuxConfigSchema.optional(),
+  websearch: WebsearchConfigSchema.optional(),
   background: BackgroundTaskConfigSchema.optional(),
   background: BackgroundTaskConfigSchema.optional(),
   fallback: FailoverConfigSchema.optional(),
   fallback: FailoverConfigSchema.optional(),
   council: CouncilConfigSchema.optional(),
   council: CouncilConfigSchema.optional(),

+ 40 - 25
src/index.ts

@@ -1,7 +1,7 @@
 import type { Plugin } from '@opencode-ai/plugin';
 import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs } from './agents';
 import { createAgents, getAgentConfigs } from './agents';
-import { BackgroundTaskManager, TmuxSessionManager } from './background';
-import { loadPluginConfig, type TmuxConfig } from './config';
+import { BackgroundTaskManager, MultiplexerSessionManager } from './background';
+import { loadPluginConfig, type MultiplexerConfig } from './config';
 import { parseList } from './config/agent-mcps';
 import { parseList } from './config/agent-mcps';
 import { CouncilManager } from './council';
 import { CouncilManager } from './council';
 import {
 import {
@@ -15,6 +15,7 @@ import {
   ForegroundFallbackManager,
   ForegroundFallbackManager,
 } from './hooks';
 } from './hooks';
 import { createBuiltinMcps } from './mcp';
 import { createBuiltinMcps } from './mcp';
+import { getMultiplexer, startAvailabilityCheck } from './multiplexer';
 import {
 import {
   ast_grep_replace,
   ast_grep_replace,
   ast_grep_search,
   ast_grep_search,
@@ -26,7 +27,6 @@ import {
   lsp_rename,
   lsp_rename,
   setUserLspConfig,
   setUserLspConfig,
 } from './tools';
 } from './tools';
-import { startTmuxCheck } from './utils';
 import { log } from './utils/logger';
 import { log } from './utils/logger';
 
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
@@ -72,29 +72,38 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     }
     }
   }
   }
 
 
-  // Parse tmux config with defaults
-  const tmuxConfig: TmuxConfig = {
-    enabled: config.tmux?.enabled ?? false,
-    layout: config.tmux?.layout ?? 'main-vertical',
-    main_pane_size: config.tmux?.main_pane_size ?? 60,
+  // Parse multiplexer config with defaults
+  const multiplexerConfig: MultiplexerConfig = {
+    type: config.multiplexer?.type ?? 'none',
+    layout: config.multiplexer?.layout ?? 'main-vertical',
+    main_pane_size: config.multiplexer?.main_pane_size ?? 60,
   };
   };
 
 
-  log('[plugin] initialized with tmux config', {
-    tmuxConfig,
-    rawTmuxConfig: config.tmux,
+  // Get multiplexer instance for capability checks
+  const multiplexer = getMultiplexer(multiplexerConfig);
+  const multiplexerEnabled =
+    multiplexerConfig.type !== 'none' && multiplexer !== null;
+
+  log('[plugin] initialized with multiplexer config', {
+    multiplexerConfig,
+    enabled: multiplexerEnabled,
     directory: ctx.directory,
     directory: ctx.directory,
   });
   });
 
 
-  // Start background tmux check if enabled
-  if (tmuxConfig.enabled) {
-    startTmuxCheck();
+  // Start background availability check if enabled
+  if (multiplexerEnabled) {
+    startAvailabilityCheck(multiplexerConfig);
   }
   }
 
 
-  const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig, config);
+  const backgroundManager = new BackgroundTaskManager(
+    ctx,
+    multiplexerConfig,
+    config,
+  );
   const backgroundTools = createBackgroundTools(
   const backgroundTools = createBackgroundTools(
     ctx,
     ctx,
     backgroundManager,
     backgroundManager,
-    tmuxConfig,
+    multiplexerConfig,
     config,
     config,
   );
   );
 
 
@@ -106,15 +115,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           ctx,
           ctx,
           config,
           config,
           backgroundManager.getDepthTracker(),
           backgroundManager.getDepthTracker(),
-          tmuxConfig.enabled,
+          multiplexerEnabled,
         ),
         ),
       )
       )
     : {};
     : {};
 
 
   const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
   const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
 
 
-  // Initialize TmuxSessionManager to handle OpenCode's built-in Task tool sessions
-  const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
+  // Initialize MultiplexerSessionManager to handle OpenCode's built-in Task tool sessions
+  const multiplexerSessionManager = new MultiplexerSessionManager(
+    ctx,
+    multiplexerConfig,
+  );
 
 
   // Initialize auto-update checker hook
   // Initialize auto-update checker hook
   const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
   const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
@@ -348,8 +360,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Handle auto-update checking
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
       await autoUpdateChecker.event(input);
 
 
-      // Handle tmux pane spawning for OpenCode's Task tool sessions
-      await tmuxSessionManager.onSessionCreated(
+      // Handle multiplexer pane spawning for OpenCode's Task tool sessions
+      await multiplexerSessionManager.onSessionCreated(
         input.event as {
         input.event as {
           type: string;
           type: string;
           properties?: {
           properties?: {
@@ -360,14 +372,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
 
       // Handle session.status events for:
       // Handle session.status events for:
       // 1. BackgroundTaskManager: completion detection
       // 1. BackgroundTaskManager: completion detection
-      // 2. TmuxSessionManager: pane cleanup
+      // 2. MultiplexerSessionManager: pane cleanup
       await backgroundManager.handleSessionStatus(
       await backgroundManager.handleSessionStatus(
         input.event as {
         input.event as {
           type: string;
           type: string;
           properties?: { sessionID?: string; status?: { type: string } };
           properties?: { sessionID?: string; status?: { type: string } };
         },
         },
       );
       );
-      await tmuxSessionManager.onSessionStatus(
+      await multiplexerSessionManager.onSessionStatus(
         input.event as {
         input.event as {
           type: string;
           type: string;
           properties?: { sessionID?: string; status?: { type: string } };
           properties?: { sessionID?: string; status?: { type: string } };
@@ -376,14 +388,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
 
       // Handle session.deleted events for:
       // Handle session.deleted events for:
       // 1. BackgroundTaskManager: task cleanup
       // 1. BackgroundTaskManager: task cleanup
-      // 2. TmuxSessionManager: pane cleanup
+      // 2. MultiplexerSessionManager: pane cleanup
       await backgroundManager.handleSessionDeleted(
       await backgroundManager.handleSessionDeleted(
         input.event as {
         input.event as {
           type: string;
           type: string;
           properties?: { info?: { id?: string }; sessionID?: string };
           properties?: { info?: { id?: string }; sessionID?: string };
         },
         },
       );
       );
-      await tmuxSessionManager.onSessionDeleted(
+      await multiplexerSessionManager.onSessionDeleted(
         input.event as {
         input.event as {
           type: string;
           type: string;
           properties?: { sessionID?: string };
           properties?: { sessionID?: string };
@@ -461,6 +473,9 @@ export type {
   AgentName,
   AgentName,
   AgentOverrideConfig,
   AgentOverrideConfig,
   McpName,
   McpName,
+  MultiplexerConfig,
+  MultiplexerLayout,
+  MultiplexerType,
   PluginConfig,
   PluginConfig,
   TmuxConfig,
   TmuxConfig,
   TmuxLayout,
   TmuxLayout,

+ 103 - 0
src/multiplexer/factory.ts

@@ -0,0 +1,103 @@
+/**
+ * Multiplexer factory - creates the appropriate multiplexer instance
+ */
+
+import type { MultiplexerConfig, MultiplexerType } from '../config/schema';
+import { log } from '../utils/logger';
+import { TmuxMultiplexer } from './tmux';
+import type { Multiplexer } from './types';
+import { ZellijMultiplexer } from './zellij';
+
+const multiplexerCache = new Map<MultiplexerType | 'auto', Multiplexer>();
+
+/**
+ * Create or retrieve a multiplexer instance based on config
+ */
+export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
+  const { type } = config;
+
+  if (type === 'none') {
+    return null;
+  }
+
+  // Return cached instance if available
+  const cached = multiplexerCache.get(type);
+  if (cached) {
+    return cached;
+  }
+
+  // Create new instance
+  let multiplexer: Multiplexer;
+  let actualType: MultiplexerType;
+
+  switch (type) {
+    case 'tmux':
+      multiplexer = new TmuxMultiplexer(config.layout, config.main_pane_size);
+      actualType = 'tmux';
+      break;
+    case 'zellij':
+      multiplexer = new ZellijMultiplexer(config.layout, config.main_pane_size);
+      actualType = 'zellij';
+      break;
+    case 'auto': {
+      // Auto-detect based on environment variables only
+      // Note: Does NOT fall back to binary availability checks
+      if (process.env.TMUX) {
+        multiplexer = new TmuxMultiplexer(config.layout, config.main_pane_size);
+        actualType = 'tmux';
+      } else if (process.env.ZELLIJ) {
+        multiplexer = new ZellijMultiplexer(
+          config.layout,
+          config.main_pane_size,
+        );
+        actualType = 'zellij';
+      } else {
+        // Not inside any session, disable multiplexer
+        log('[multiplexer] auto: not inside any session, disabling');
+        return null;
+      }
+      break;
+    }
+    default:
+      log(`[multiplexer] Unknown type: ${type}`);
+      return null;
+  }
+
+  // Cache the instance under the actual type (not 'auto')
+  multiplexerCache.set(actualType, multiplexer);
+  log(`[multiplexer] Created ${actualType} instance`);
+
+  return multiplexer;
+}
+
+/**
+ * Clear the multiplexer cache (useful for testing)
+ */
+export function clearMultiplexerCache(): void {
+  multiplexerCache.clear();
+}
+
+/**
+ * Get the effective multiplexer type for auto mode
+ * Returns the actual type that would be used (tmux/zellij/none)
+ */
+export function getAutoMultiplexerType(): 'tmux' | 'zellij' | 'none' {
+  if (process.env.TMUX) {
+    return 'tmux';
+  }
+  if (process.env.ZELLIJ) {
+    return 'zellij';
+  }
+  return 'none';
+}
+
+/**
+ * Start background availability check for a multiplexer
+ */
+export function startAvailabilityCheck(config: MultiplexerConfig): void {
+  const multiplexer = getMultiplexer(config);
+  if (multiplexer) {
+    // Fire and forget - don't await
+    multiplexer.isAvailable().catch(() => {});
+  }
+}

+ 13 - 0
src/multiplexer/index.ts

@@ -0,0 +1,13 @@
+/**
+ * Multiplexer module exports
+ */
+
+export {
+  clearMultiplexerCache,
+  getMultiplexer,
+  startAvailabilityCheck,
+} from './factory';
+export { TmuxMultiplexer } from './tmux';
+export type { Multiplexer, PaneResult } from './types';
+export { isServerRunning } from './types';
+export { ZellijMultiplexer } from './zellij';

+ 247 - 0
src/multiplexer/tmux/index.ts

@@ -0,0 +1,247 @@
+/**
+ * Tmux multiplexer implementation
+ */
+
+import { spawn } from 'bun';
+import type { MultiplexerLayout } from '../../config/schema';
+import { log } from '../../utils/logger';
+import type { Multiplexer, PaneResult } from '../types';
+
+export class TmuxMultiplexer implements Multiplexer {
+  readonly type = 'tmux' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private storedLayout: MultiplexerLayout;
+  private storedMainPaneSize: number;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    this.storedLayout = layout;
+    this.storedMainPaneSize = mainPaneSize;
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.hasChecked) {
+      return this.binaryPath !== null;
+    }
+
+    this.binaryPath = await this.findBinary();
+    this.hasChecked = true;
+    return this.binaryPath !== null;
+  }
+
+  isInsideSession(): boolean {
+    return !!process.env.TMUX;
+  }
+
+  async spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+  ): Promise<PaneResult> {
+    const tmux = await this.getBinary();
+    if (!tmux) {
+      log('[tmux] spawnPane: tmux binary not found');
+      return { success: false };
+    }
+
+    try {
+      // Build the attach command
+      const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+
+      // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
+      const args = [
+        'split-window',
+        '-h', // Horizontal split (pane to the right)
+        '-d', // Don't switch focus
+        '-P', // Print pane info
+        '-F',
+        '#{pane_id}', // Format: just the pane ID
+        opencodeCmd,
+      ];
+
+      log('[tmux] spawnPane: executing', { tmux, args });
+
+      const proc = spawn([tmux, ...args], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      const stdout = await new Response(proc.stdout).text();
+      const stderr = await new Response(proc.stderr).text();
+      const paneId = stdout.trim();
+
+      log('[tmux] spawnPane: result', {
+        exitCode,
+        paneId,
+        stderr: stderr.trim(),
+      });
+
+      if (exitCode === 0 && paneId) {
+        // Rename the pane for visibility
+        const renameProc = spawn(
+          [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
+          { stdout: 'ignore', stderr: 'ignore' },
+        );
+        await renameProc.exited;
+
+        // Apply layout
+        await this.applyLayout(this.storedLayout, this.storedMainPaneSize);
+
+        log('[tmux] spawnPane: SUCCESS', { paneId });
+        return { success: true, paneId };
+      }
+
+      return { success: false };
+    } catch (err) {
+      log('[tmux] spawnPane: exception', { error: String(err) });
+      return { success: false };
+    }
+  }
+
+  async closePane(paneId: string): Promise<boolean> {
+    if (!paneId) {
+      log('[tmux] closePane: no paneId provided');
+      return false;
+    }
+
+    const tmux = await this.getBinary();
+    if (!tmux) {
+      log('[tmux] closePane: tmux binary not found');
+      return false;
+    }
+
+    try {
+      // Send Ctrl+C for graceful shutdown
+      log('[tmux] closePane: sending Ctrl+C', { paneId });
+      const ctrlCProc = spawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      await ctrlCProc.exited;
+
+      // Wait for graceful shutdown
+      await new Promise((r) => setTimeout(r, 250));
+
+      // Kill the pane
+      log('[tmux] closePane: killing pane', { paneId });
+      const proc = spawn([tmux, 'kill-pane', '-t', paneId], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      const stderr = await new Response(proc.stderr).text();
+
+      log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
+
+      if (exitCode === 0) {
+        // Reapply layout to rebalance
+        await this.applyLayout(this.storedLayout, this.storedMainPaneSize);
+        return true;
+      }
+
+      // Pane might already be closed
+      log('[tmux] closePane: failed (pane may already be closed)', { paneId });
+      return false;
+    } catch (err) {
+      log('[tmux] closePane: exception', { error: String(err) });
+      return false;
+    }
+  }
+
+  async applyLayout(
+    layout: MultiplexerLayout,
+    mainPaneSize: number,
+  ): Promise<void> {
+    const tmux = await this.getBinary();
+    if (!tmux) return;
+
+    // Store for later use
+    this.storedLayout = layout;
+    this.storedMainPaneSize = mainPaneSize;
+
+    try {
+      // Apply the layout
+      const layoutProc = spawn([tmux, 'select-layout', layout], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      await layoutProc.exited;
+
+      // For main-* layouts, set the main pane size
+      if (layout === 'main-horizontal' || layout === 'main-vertical') {
+        const sizeOption =
+          layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
+
+        const sizeProc = spawn(
+          [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
+          {
+            stdout: 'pipe',
+            stderr: 'pipe',
+          },
+        );
+        await sizeProc.exited;
+
+        // Reapply layout to use the new size
+        const reapplyProc = spawn([tmux, 'select-layout', layout], {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        });
+        await reapplyProc.exited;
+      }
+
+      log('[tmux] applyLayout: applied', { layout, mainPaneSize });
+    } catch (err) {
+      log('[tmux] applyLayout: exception', { error: String(err) });
+    }
+  }
+
+  private async getBinary(): Promise<string | null> {
+    await this.isAvailable();
+    return this.binaryPath;
+  }
+
+  private async findBinary(): Promise<string | null> {
+    const isWindows = process.platform === 'win32';
+    const cmd = isWindows ? 'where' : 'which';
+
+    try {
+      const proc = spawn([cmd, 'tmux'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) {
+        log("[tmux] findBinary: 'which tmux' failed", { exitCode });
+        return null;
+      }
+
+      const stdout = await new Response(proc.stdout).text();
+      const path = stdout.trim().split('\n')[0];
+      if (!path) {
+        log('[tmux] findBinary: no path in output');
+        return null;
+      }
+
+      // Verify it works
+      const verifyProc = spawn([path, '-V'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      const verifyExit = await verifyProc.exited;
+      if (verifyExit !== 0) {
+        log('[tmux] findBinary: tmux -V failed', { path, verifyExit });
+        return null;
+      }
+
+      log('[tmux] findBinary: found', { path });
+      return path;
+    } catch (err) {
+      log('[tmux] findBinary: exception', { error: String(err) });
+      return null;
+    }
+  }
+}

+ 98 - 0
src/multiplexer/types.ts

@@ -0,0 +1,98 @@
+/**
+ * Multiplexer abstraction layer
+ *
+ * Provides a unified interface for terminal multiplexers (tmux, zellij, etc.)
+ * to spawn and manage panes for background task visualization.
+ */
+
+import type { MultiplexerConfig, MultiplexerLayout } from '../config/schema';
+
+export interface PaneResult {
+  success: boolean;
+  paneId?: string;
+}
+
+/**
+ * Core multiplexer interface
+ * Implementations: TmuxMultiplexer, ZellijMultiplexer
+ */
+export interface Multiplexer {
+  readonly type: 'tmux' | 'zellij';
+
+  /**
+   * Check if the multiplexer binary is available on the system
+   */
+  isAvailable(): Promise<boolean>;
+
+  /**
+   * Check if currently running inside a multiplexer session
+   */
+  isInsideSession(): boolean;
+
+  /**
+   * Spawn a new pane running the given command
+   * @param sessionId - The OpenCode session ID to attach to
+   * @param description - Human-readable description for the pane
+   * @param serverUrl - The OpenCode server URL to attach to
+   * @returns PaneResult with pane ID for later cleanup
+   */
+  spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+  ): Promise<PaneResult>;
+
+  /**
+   * Close a pane by its ID
+   * @param paneId - The pane ID returned by spawnPane
+   * @returns true if successfully closed
+   */
+  closePane(paneId: string): Promise<boolean>;
+
+  /**
+   * Apply layout to rebalance panes
+   * @param layout - The layout type to apply
+   * @param mainPaneSize - Percentage for main pane (for main-* layouts)
+   */
+  applyLayout(layout: MultiplexerLayout, mainPaneSize: number): Promise<void>;
+}
+
+/**
+ * Factory function type for creating multiplexer instances
+ */
+export type MultiplexerFactory = (config: MultiplexerConfig) => Multiplexer;
+
+/**
+ * Server health check utility (shared across implementations)
+ */
+export async function isServerRunning(
+  serverUrl: string,
+  timeoutMs = 3000,
+  maxAttempts = 2,
+): Promise<boolean> {
+  const healthUrl = new URL('/health', serverUrl).toString();
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), timeoutMs);
+
+    let response: Response | null = null;
+    try {
+      response = await fetch(healthUrl, { signal: controller.signal }).catch(
+        () => null,
+      );
+    } finally {
+      clearTimeout(timeout);
+    }
+
+    if (response?.ok) {
+      return true;
+    }
+
+    if (attempt < maxAttempts) {
+      await new Promise((r) => setTimeout(r, 250));
+    }
+  }
+
+  return false;
+}

+ 448 - 0
src/multiplexer/zellij/index.ts

@@ -0,0 +1,448 @@
+/**
+ * Zellij multiplexer implementation
+ *
+ * Creates a dedicated "opencode-agents" tab for all sub-agent panes.
+ * - First sub-agent uses the default pane from new-tab
+ * - Subsequent sub-agents create new panes
+ * - User stays in their original tab
+ */
+
+import { spawn } from 'bun';
+import type { MultiplexerLayout } from '../../config/schema';
+import type { Multiplexer, PaneResult } from '../types';
+
+interface ZellijTabInfo {
+  position: number;
+  name: string;
+  active: boolean;
+  tab_id: number;
+}
+
+export class ZellijMultiplexer implements Multiplexer {
+  readonly type = 'zellij' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private storedLayout: MultiplexerLayout;
+  private storedMainPaneSize: number;
+  private agentTabId: string | null = null;
+  private firstPaneId: string | null = null;
+  private firstPaneUsed = false;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    // Note: Zellij does NOT support layout configuration like tmux.
+    // These params are accepted for API consistency but are no-ops.
+    // Zellij uses its own native layout algorithm for pane arrangement.
+    this.storedLayout = layout;
+    this.storedMainPaneSize = mainPaneSize;
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.hasChecked) {
+      return this.binaryPath !== null;
+    }
+    this.binaryPath = await this.findBinary();
+    this.hasChecked = true;
+    return this.binaryPath !== null;
+  }
+
+  isInsideSession(): boolean {
+    return !!process.env.ZELLIJ;
+  }
+
+  async spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+  ): Promise<PaneResult> {
+    const zellij = await this.getBinary();
+    if (!zellij) return { success: false };
+
+    try {
+      // Ensure agent tab exists on first call
+      if (!this.agentTabId) {
+        const result = await this.ensureAgentTab(zellij);
+        if (!result) return { success: false };
+        this.agentTabId = result.tabId;
+        this.firstPaneId = result.firstPaneId;
+      }
+
+      // Use the default pane from new-tab for the first sub-agent
+      if (!this.firstPaneUsed && this.firstPaneId) {
+        const success = await this.runInPane(
+          zellij,
+          this.firstPaneId,
+          sessionId,
+          serverUrl,
+          description,
+        );
+        if (success) {
+          this.firstPaneUsed = true;
+          return { success: true, paneId: this.firstPaneId };
+        }
+        // fall through to createPaneInAgentTab on failure
+      }
+
+      // Create additional pane
+      return await this.createPaneInAgentTab(
+        zellij,
+        sessionId,
+        serverUrl,
+        description,
+      );
+    } catch {
+      return { success: false };
+    }
+  }
+
+  private async createPaneInAgentTab(
+    zellij: string,
+    sessionId: string,
+    serverUrl: string,
+    description: string,
+  ): Promise<PaneResult> {
+    const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+    const paneName = description.slice(0, 30).replace(/"/g, '\\"');
+
+    const currentTabId = await this.getCurrentTabId(zellij);
+    const inAgentTab = currentTabId === this.agentTabId;
+
+    if (inAgentTab) {
+      // Already in agent tab, create pane directly
+      const args = [
+        'action',
+        'new-pane',
+        '--name',
+        paneName,
+        '--close-on-exit',
+        '--',
+        'sh',
+        '-c',
+        opencodeCmd,
+      ];
+
+      const proc = spawn([zellij, ...args], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      const stdout = await new Response(proc.stdout).text();
+      const paneId = stdout.trim();
+
+      // Accept success if exit code is 0 and we got a valid pane ID
+      if (exitCode === 0 && paneId?.startsWith('terminal_')) {
+        return { success: true, paneId };
+      }
+      return { success: false };
+    }
+
+    if (!this.agentTabId) {
+      return { success: false };
+    }
+
+    // Get current tab before switching
+    const originalTab = await this.getCurrentTabId(zellij);
+
+    // Switch to agent tab
+    await spawn([zellij, 'action', 'go-to-tab-by-id', this.agentTabId], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    }).exited;
+
+    // Create pane
+    const args = [
+      'action',
+      'new-pane',
+      '--name',
+      paneName,
+      '--close-on-exit',
+      '--',
+      'sh',
+      '-c',
+      opencodeCmd,
+    ];
+
+    const proc = spawn([zellij, ...args], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const exitCode = await proc.exited;
+    const stdout = await new Response(proc.stdout).text();
+    const paneId = stdout.trim();
+
+    // Switch back to original tab
+    if (originalTab) {
+      await spawn([zellij, 'action', 'go-to-tab-by-id', String(originalTab)], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+    }
+
+    // Accept success if exit code is 0 and we got a valid pane ID
+    if (exitCode === 0 && paneId?.startsWith('terminal_')) {
+      return { success: true, paneId };
+    }
+    return { success: false };
+  }
+
+  private async runInPane(
+    zellij: string,
+    paneId: string,
+    sessionId: string,
+    serverUrl: string,
+    description: string,
+  ): Promise<boolean> {
+    try {
+      const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+
+      await spawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+
+      await spawn(
+        [zellij, 'action', 'rename-pane', '--name', description.slice(0, 30)],
+        { stdout: 'ignore', stderr: 'ignore' },
+      ).exited;
+
+      await spawn([zellij, 'action', 'write-chars', opencodeCmd], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+
+      await spawn([zellij, 'action', 'write-chars', '\n'], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  private async ensureAgentTab(
+    zellij: string,
+  ): Promise<{ tabId: string; firstPaneId: string } | null> {
+    try {
+      // Try to find existing tab
+      const existingTab = await this.findTabByName(zellij, 'opencode-agents');
+      if (existingTab) {
+        const firstPane = await this.getFirstPaneInTab(
+          zellij,
+          existingTab.tabId,
+        );
+        return {
+          tabId: existingTab.tabId,
+          firstPaneId: firstPane || 'terminal_0',
+        };
+      }
+
+      // Get panes before creating tab
+      const beforePanes = await this.listPanes(zellij);
+
+      // Create new tab
+      const createProc = spawn(
+        [zellij, 'action', 'new-tab', '--name', 'opencode-agents'],
+        { stdout: 'pipe', stderr: 'pipe' },
+      );
+      const createExit = await createProc.exited;
+      if (createExit !== 0) return null;
+
+      // Get the new tab info
+      const newTab = await this.findTabByName(zellij, 'opencode-agents');
+      if (!newTab) return null;
+
+      // Get the new pane
+      const afterPanes = await this.listPanes(zellij);
+      const newPane = afterPanes.find((p) => !beforePanes.includes(p));
+
+      return { tabId: newTab.tabId, firstPaneId: newPane || 'terminal_0' };
+    } catch {
+      return null;
+    }
+  }
+
+  private async getFirstPaneInTab(
+    zellij: string,
+    tabId: string,
+  ): Promise<string | null> {
+    const originalTab = await this.getCurrentTabId(zellij);
+    await spawn([zellij, 'action', 'go-to-tab-by-id', tabId], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    }).exited;
+
+    const panes = await this.listPanes(zellij);
+
+    // Restore original tab
+    if (originalTab) {
+      await spawn([zellij, 'action', 'go-to-tab-by-id', String(originalTab)], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+    }
+
+    return panes[0] || null;
+  }
+
+  private async findTabByName(
+    zellij: string,
+    name: string,
+  ): Promise<{ tabId: string; name: string } | null> {
+    try {
+      const proc = spawn([zellij, 'action', 'list-tabs', '--json'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) return this.findTabByNameText(zellij, name);
+
+      const stdout = await new Response(proc.stdout).text();
+
+      try {
+        const tabs: ZellijTabInfo[] = JSON.parse(stdout);
+        for (const tab of tabs) {
+          if (tab.name === name) {
+            return { tabId: String(tab.tab_id), name: tab.name };
+          }
+        }
+      } catch {
+        return this.findTabByNameText(zellij, name);
+      }
+      return null;
+    } catch {
+      return null;
+    }
+  }
+
+  private async findTabByNameText(
+    zellij: string,
+    name: string,
+  ): Promise<{ tabId: string; name: string } | null> {
+    try {
+      const proc = spawn([zellij, 'action', 'list-tabs'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) return null;
+
+      const stdout = await new Response(proc.stdout).text();
+      const lines = stdout.split('\n');
+
+      for (const line of lines) {
+        const parts = line.trim().split(/\s+/);
+        if (parts.length >= 3 && parts[2] === name) {
+          return { tabId: parts[0], name: parts[2] };
+        }
+      }
+      return null;
+    } catch {
+      return null;
+    }
+  }
+
+  private async getCurrentTabId(zellij: string): Promise<string | null> {
+    try {
+      const proc = spawn([zellij, 'action', 'current-tab-info', '--json'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) return null;
+
+      const stdout = await new Response(proc.stdout).text();
+      try {
+        const info = JSON.parse(stdout);
+        return String(info.tab_id);
+      } catch {
+        return null;
+      }
+    } catch {
+      return null;
+    }
+  }
+
+  private async listPanes(zellij: string): Promise<string[]> {
+    try {
+      const proc = spawn([zellij, 'action', 'list-panes'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) return [];
+
+      const stdout = await new Response(proc.stdout).text();
+      return stdout
+        .split('\n')
+        .slice(1)
+        .map((line) => line.trim().split(/\s+/)[0])
+        .filter((id) => id?.startsWith('terminal_'));
+    } catch {
+      return [];
+    }
+  }
+
+  async closePane(paneId: string): Promise<boolean> {
+    if (!paneId || paneId === 'unknown') return true;
+
+    const zellij = await this.getBinary();
+    if (!zellij) return false;
+
+    try {
+      // Send Ctrl+C for graceful shutdown
+      await spawn([zellij, 'action', 'write', '--pane-id', paneId, '\u0003'], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+
+      await new Promise((r) => setTimeout(r, 250));
+
+      // Close the pane
+      const proc = spawn(
+        [zellij, 'action', 'close-pane', '--pane-id', paneId],
+        { stdout: 'pipe', stderr: 'pipe' },
+      );
+
+      const exitCode = await proc.exited;
+      return exitCode === 0 || exitCode === 1;
+    } catch {
+      return false;
+    }
+  }
+
+  async applyLayout(
+    _layout: MultiplexerLayout,
+    _mainPaneSize: number,
+  ): Promise<void> {
+    // No-op for zellij - zellij uses its own native layout algorithm.
+    // Unlike tmux, zellij does not support programmatic layout control.
+  }
+
+  private async getBinary(): Promise<string | null> {
+    await this.isAvailable();
+    return this.binaryPath;
+  }
+
+  private async findBinary(): Promise<string | null> {
+    const cmd = process.platform === 'win32' ? 'where' : 'which';
+    try {
+      const proc = spawn([cmd, 'zellij'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      if ((await proc.exited) !== 0) return null;
+      const stdout = await new Response(proc.stdout).text();
+      return stdout.trim().split('\n')[0] || null;
+    } catch {
+      return null;
+    }
+  }
+}

+ 3 - 3
src/tools/background.ts

@@ -6,7 +6,7 @@ import {
 import type { BackgroundTaskManager } from '../background';
 import type { BackgroundTaskManager } from '../background';
 import type { PluginConfig } from '../config';
 import type { PluginConfig } from '../config';
 import { SUBAGENT_NAMES } from '../config';
 import { SUBAGENT_NAMES } from '../config';
-import type { TmuxConfig } from '../config/schema';
+import type { MultiplexerConfig } from '../config/schema';
 
 
 const z = tool.schema;
 const z = tool.schema;
 
 
@@ -14,14 +14,14 @@ const z = tool.schema;
  * Creates background task management tools for the plugin.
  * Creates background task management tools for the plugin.
  * @param _ctx - Plugin input context
  * @param _ctx - Plugin input context
  * @param manager - Background task manager for launching and tracking tasks
  * @param manager - Background task manager for launching and tracking tasks
- * @param _tmuxConfig - Optional tmux configuration for session management
+ * @param _multiplexerConfig - Optional multiplexer configuration for session management
  * @param _pluginConfig - Optional plugin configuration for agent variants
  * @param _pluginConfig - Optional plugin configuration for agent variants
  * @returns Object containing background_task, background_output, and background_cancel tools
  * @returns Object containing background_task, background_output, and background_cancel tools
  */
  */
 export function createBackgroundTools(
 export function createBackgroundTools(
   _ctx: PluginInput,
   _ctx: PluginInput,
   manager: BackgroundTaskManager,
   manager: BackgroundTaskManager,
-  _tmuxConfig?: TmuxConfig,
+  _multiplexerConfig?: MultiplexerConfig,
   _pluginConfig?: PluginConfig,
   _pluginConfig?: PluginConfig,
 ): Record<string, ToolDefinition> {
 ): Record<string, ToolDefinition> {
   const agentNames = SUBAGENT_NAMES.join(', ');
   const agentNames = SUBAGENT_NAMES.join(', ');

+ 0 - 1
src/utils/index.ts

@@ -4,5 +4,4 @@ export * from './internal-initiator';
 export { log } from './logger';
 export { log } from './logger';
 export * from './polling';
 export * from './polling';
 export * from './session';
 export * from './session';
-export * from './tmux';
 export { extractZip } from './zip-extractor';
 export { extractZip } from './zip-extractor';

+ 4 - 1
src/utils/logger.test.ts

@@ -5,7 +5,10 @@ import * as path from 'node:path';
 import { log } from './logger';
 import { log } from './logger';
 
 
 describe('logger', () => {
 describe('logger', () => {
-  const testLogFile = path.join(os.tmpdir(), 'oh-my-opencode-slim.log');
+  const testLogFile = path.join(
+    process.env.HOME || os.tmpdir(),
+    '.local/share/opencode/oh-my-opencode-slim.log',
+  );
 
 
   beforeEach(() => {
   beforeEach(() => {
     // Clean up log file before each test
     // Clean up log file before each test

+ 11 - 1
src/utils/logger.ts

@@ -2,7 +2,17 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import * as path from 'node:path';
 
 
-const logFile = path.join(os.tmpdir(), 'oh-my-opencode-slim.log');
+const logFile = path.join(
+  process.env.HOME || os.tmpdir(),
+  '.local/share/opencode/oh-my-opencode-slim.log',
+);
+
+// Ensure directory exists
+try {
+  fs.mkdirSync(path.dirname(logFile), { recursive: true });
+} catch {
+  // Ignore
+}
 
 
 export function log(message: string, data?: unknown): void {
 export function log(message: string, data?: unknown): void {
   try {
   try {

+ 0 - 31
src/utils/tmux.test.ts

@@ -1,31 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { resetServerCheck } from './tmux';
-
-describe('tmux utils', () => {
-  describe('resetServerCheck', () => {
-    test('resetServerCheck is exported and is a function', () => {
-      expect(typeof resetServerCheck).toBe('function');
-    });
-
-    test('resetServerCheck does not throw', () => {
-      expect(() => resetServerCheck()).not.toThrow();
-    });
-
-    test('can be called multiple times', () => {
-      expect(() => {
-        resetServerCheck();
-        resetServerCheck();
-        resetServerCheck();
-      }).not.toThrow();
-    });
-  });
-
-  // Note: Testing getTmuxPath, spawnTmuxPane, and closeTmuxPane requires:
-  // 1. Mocking Bun's spawn function
-  // 2. Mocking file system operations
-  // 3. Running in a tmux environment
-  // 4. Mocking HTTP fetch for server checks
-  //
-  // These are better suited for integration tests rather than unit tests.
-  // The current tests cover the simple, pure functions that don't require mocking.
-});

+ 0 - 368
src/utils/tmux.ts

@@ -1,368 +0,0 @@
-import { spawn } from 'bun';
-import type { TmuxConfig, TmuxLayout } from '../config/schema';
-import { log } from './logger';
-
-let tmuxPath: string | null = null;
-let tmuxChecked = false;
-
-// Store config for reapplying layout on close
-let storedConfig: TmuxConfig | null = null;
-
-// Cache server availability check
-let serverAvailable: boolean | null = null;
-let serverCheckUrl: string | null = null;
-
-/**
- * Check if the OpenCode HTTP server is actually running.
- * This is needed because ctx.serverUrl may return a fallback URL even when no server is running.
- */
-async function isServerRunning(serverUrl: string): Promise<boolean> {
-  // Use cached result if checking the same URL
-  if (serverCheckUrl === serverUrl && serverAvailable === true) {
-    return true;
-  }
-
-  const healthUrl = new URL('/health', serverUrl).toString();
-  const timeoutMs = 3000;
-  const maxAttempts = 2;
-
-  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-    const controller = new AbortController();
-    const timeout = setTimeout(() => controller.abort(), timeoutMs);
-
-    let response: Response | null = null;
-    try {
-      response = await fetch(healthUrl, { signal: controller.signal }).catch(
-        () => null,
-      );
-    } finally {
-      clearTimeout(timeout);
-    }
-
-    const available = response?.ok ?? false;
-    if (available) {
-      serverCheckUrl = serverUrl;
-      serverAvailable = true;
-      log('[tmux] isServerRunning: checked', { serverUrl, available, attempt });
-      return true;
-    }
-
-    if (attempt < maxAttempts) {
-      await new Promise((r) => setTimeout(r, 250));
-    }
-  }
-
-  log('[tmux] isServerRunning: checked', { serverUrl, available: false });
-  return false;
-}
-
-/**
- * Reset the server availability cache (useful when server might have started)
- */
-export function resetServerCheck(): void {
-  serverAvailable = null;
-  serverCheckUrl = null;
-}
-
-/**
- * Find tmux binary path
- */
-async function findTmuxPath(): Promise<string | null> {
-  const isWindows = process.platform === 'win32';
-  const cmd = isWindows ? 'where' : 'which';
-
-  try {
-    const proc = spawn([cmd, 'tmux'], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-
-    const exitCode = await proc.exited;
-    if (exitCode !== 0) {
-      log("[tmux] findTmuxPath: 'which tmux' failed", { exitCode });
-      return null;
-    }
-
-    const stdout = await new Response(proc.stdout).text();
-    const path = stdout.trim().split('\n')[0];
-    if (!path) {
-      log('[tmux] findTmuxPath: no path in output');
-      return null;
-    }
-
-    // Verify it works
-    const verifyProc = spawn([path, '-V'], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-    const verifyExit = await verifyProc.exited;
-    if (verifyExit !== 0) {
-      log('[tmux] findTmuxPath: tmux -V failed', { path, verifyExit });
-      return null;
-    }
-
-    log('[tmux] findTmuxPath: found tmux', { path });
-    return path;
-  } catch (err) {
-    log('[tmux] findTmuxPath: exception', { error: String(err) });
-    return null;
-  }
-}
-
-/**
- * Get cached tmux path, initializing if needed
- */
-export async function getTmuxPath(): Promise<string | null> {
-  if (tmuxChecked) {
-    return tmuxPath;
-  }
-
-  tmuxPath = await findTmuxPath();
-  tmuxChecked = true;
-  log('[tmux] getTmuxPath: initialized', { tmuxPath });
-  return tmuxPath;
-}
-
-/**
- * Check if we're running inside tmux
- */
-export function isInsideTmux(): boolean {
-  return !!process.env.TMUX;
-}
-
-/**
- * Apply a tmux layout to the current window
- */
-async function applyLayout(
-  tmux: string,
-  layout: TmuxLayout,
-  mainPaneSize: number,
-): Promise<void> {
-  try {
-    // Apply the layout
-    const layoutProc = spawn([tmux, 'select-layout', layout], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-    await layoutProc.exited;
-
-    // For main-* layouts, set the main pane size
-    if (layout === 'main-horizontal' || layout === 'main-vertical') {
-      const sizeOption =
-        layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
-
-      const sizeProc = spawn(
-        [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
-        {
-          stdout: 'pipe',
-          stderr: 'pipe',
-        },
-      );
-      await sizeProc.exited;
-
-      // Reapply layout to use the new size
-      const reapplyProc = spawn([tmux, 'select-layout', layout], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      await reapplyProc.exited;
-    }
-
-    log('[tmux] applyLayout: applied', { layout, mainPaneSize });
-  } catch (err) {
-    log('[tmux] applyLayout: exception', { error: String(err) });
-  }
-}
-
-export interface SpawnPaneResult {
-  success: boolean;
-  paneId?: string; // e.g., "%42"
-}
-
-/**
- * Spawn a new tmux pane running `opencode attach <serverUrl> --session <sessionId>`
- * This connects the new TUI to the existing server so it receives streaming updates.
- * After spawning, applies the configured layout to auto-rebalance all panes.
- * Returns the pane ID so it can be closed later.
- */
-export async function spawnTmuxPane(
-  sessionId: string,
-  description: string,
-  config: TmuxConfig,
-  serverUrl: string,
-): Promise<SpawnPaneResult> {
-  log('[tmux] spawnTmuxPane called', {
-    sessionId,
-    description,
-    config,
-    serverUrl,
-  });
-
-  if (!config.enabled) {
-    log('[tmux] spawnTmuxPane: config.enabled is false, skipping');
-    return { success: false };
-  }
-
-  if (!isInsideTmux()) {
-    log('[tmux] spawnTmuxPane: not inside tmux, skipping');
-    return { success: false };
-  }
-
-  // Check if the OpenCode HTTP server is actually running
-  // This is needed because serverUrl may be a fallback even when no server is running
-  const serverRunning = await isServerRunning(serverUrl);
-  if (!serverRunning) {
-    const defaultPort = process.env.OPENCODE_PORT ?? '4096';
-    log('[tmux] spawnTmuxPane: OpenCode server not running, skipping', {
-      serverUrl,
-      hint: `Start opencode with --port ${defaultPort}`,
-    });
-    return { success: false };
-  }
-
-  const tmux = await getTmuxPath();
-  if (!tmux) {
-    log('[tmux] spawnTmuxPane: tmux binary not found, skipping');
-    return { success: false };
-  }
-
-  // Store config for use in closeTmuxPane
-  storedConfig = config;
-
-  try {
-    // Use `opencode attach <url> --session <id>` to connect to the existing server
-    // This ensures the TUI receives streaming updates from the same server handling the prompt
-    const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
-
-    // Simple split - layout will handle positioning
-    // Use -h for horizontal split (new pane to the right) as default
-    const args = [
-      'split-window',
-      '-h',
-      '-d', // Don't switch focus to new pane
-      '-P', // Print pane info
-      '-F',
-      '#{pane_id}', // Format: just the pane ID
-      opencodeCmd,
-    ];
-
-    log('[tmux] spawnTmuxPane: executing', { tmux, args, opencodeCmd });
-
-    const proc = spawn([tmux, ...args], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-
-    const exitCode = await proc.exited;
-    const stdout = await new Response(proc.stdout).text();
-    const stderr = await new Response(proc.stderr).text();
-    const paneId = stdout.trim(); // e.g., "%42"
-
-    log('[tmux] spawnTmuxPane: split result', {
-      exitCode,
-      paneId,
-      stderr: stderr.trim(),
-    });
-
-    if (exitCode === 0 && paneId) {
-      // Rename the pane for visibility
-      const renameProc = spawn(
-        [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
-        { stdout: 'ignore', stderr: 'ignore' },
-      );
-      await renameProc.exited;
-
-      // Apply layout to auto-rebalance all panes
-      const layout = config.layout ?? 'main-vertical';
-      const mainPaneSize = config.main_pane_size ?? 60;
-      await applyLayout(tmux, layout, mainPaneSize);
-
-      log('[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied', {
-        paneId,
-        layout,
-      });
-      return { success: true, paneId };
-    }
-
-    return { success: false };
-  } catch (err) {
-    log('[tmux] spawnTmuxPane: exception', { error: String(err) });
-    return { success: false };
-  }
-}
-
-/**
- * Close a tmux pane by its ID and reapply layout to rebalance remaining panes
- */
-export async function closeTmuxPane(paneId: string): Promise<boolean> {
-  log('[tmux] closeTmuxPane called', { paneId });
-
-  if (!paneId) {
-    log('[tmux] closeTmuxPane: no paneId provided');
-    return false;
-  }
-
-  const tmux = await getTmuxPath();
-  if (!tmux) {
-    log('[tmux] closeTmuxPane: tmux binary not found');
-    return false;
-  }
-
-  try {
-    // Send Ctrl+C for graceful shutdown first
-    log('[tmux] closeTmuxPane: sending Ctrl+C for graceful shutdown', {
-      paneId,
-    });
-    const ctrlCProc = spawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-    await ctrlCProc.exited;
-
-    // Wait for graceful shutdown
-    await new Promise((r) => setTimeout(r, 250));
-
-    log('[tmux] closeTmuxPane: killing pane', { paneId });
-    const proc = spawn([tmux, 'kill-pane', '-t', paneId], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-
-    const exitCode = await proc.exited;
-    const stderr = await new Response(proc.stderr).text();
-
-    log('[tmux] closeTmuxPane: result', { exitCode, stderr: stderr.trim() });
-
-    if (exitCode === 0) {
-      log('[tmux] closeTmuxPane: SUCCESS, pane closed', { paneId });
-
-      // Reapply layout to rebalance remaining panes
-      if (storedConfig) {
-        const layout = storedConfig.layout ?? 'main-vertical';
-        const mainPaneSize = storedConfig.main_pane_size ?? 60;
-        await applyLayout(tmux, layout, mainPaneSize);
-        log('[tmux] closeTmuxPane: layout reapplied', { layout });
-      }
-
-      return true;
-    }
-
-    // Pane might already be closed (user closed it manually, or process exited)
-    log('[tmux] closeTmuxPane: failed (pane may already be closed)', {
-      paneId,
-    });
-    return false;
-  } catch (err) {
-    log('[tmux] closeTmuxPane: exception', { error: String(err) });
-    return false;
-  }
-}
-
-/**
- * Start background check for tmux availability
- */
-export function startTmuxCheck(): void {
-  if (!tmuxChecked) {
-    getTmuxPath().catch(() => {});
-  }
-}