Browse Source

fix(zellij): target panes by ID with fallback and serialized creation

mygo 6 days ago
parent
commit
f9fa55a5b0

+ 42 - 4
docs/multiplexer-integration.md

@@ -241,7 +241,7 @@ Please analyze this codebase and create a documentation structure.
 | Multiplexer | Status | Notes |
 |-------------|--------|-------|
 | **Tmux** | ✅ Supported | Full layout control with `main-vertical`, `main-horizontal`, `tiled`, and more |
-| **Zellij** | ✅ Supported | Creates a dedicated `opencode-agents` tab by default; can open panes in the parent OpenCode tab with `zellij_pane_mode: "current-tab"`; maps `main-*` layouts to pane directions |
+| **Zellij** | ✅ Supported | Requires Zellij **0.44.1 or newer** (the adapter gates on `zellij --version` and skips itself on older releases; 0.44.0 lacks `new-pane --tab-id`). Creates a dedicated `opencode-agents` tab by default; can open panes in the parent OpenCode tab with `zellij_pane_mode: "current-tab"`; maps `main-*` layouts to pane directions |
 | **Herdr** | ✅ Supported | Splits panes in the current Herdr workspace; maps `main-vertical`/`even-horizontal`/`tiled` layouts to right splits and `main-horizontal`/`even-vertical` to down splits; no layout rebalancing (like Zellij) |
 | **cmux** | ✅ Supported | Requires cmux 0.64.14+ (0.64.17+ recommended); creates the agent column to the right and stacks subsequent agents downward without moving focus |
 | **Kitty** | ✅ Supported | Uses `kitten @ launch` to open new windows; requires `allow_remote_control` **and** `listen_on` in kitty.conf (OpenCode must run inside a kitty window; kitty exports `KITTY_LISTEN_ON` which the plugin passes through to reach kitty from detached subagent processes). No layout rebalancing (like Zellij/Herdr) |
@@ -304,9 +304,47 @@ configurable cmux column width.
 ```
 
 In `current-tab` mode, panes are targeted to the tab that contains the parent
-OpenCode pane, even if another Zellij tab is focused when a subagent starts.
-If the parent pane cannot be resolved, it falls back to the currently focused
-tab.
+OpenCode pane (resolved from the parent pane's `ZELLIJ_PANE_ID` via
+`list-panes`), even if another Zellij tab is focused when a subagent starts.
+If the parent pane cannot be resolved, the tab target is omitted and Zellij
+places the pane in whatever tab it has focused — no tab id is guessed.
+
+### Zellij details
+
+The Zellij adapter requires **Zellij 0.44.1 or newer**. Older releases are
+rejected at availability check time: `isAvailable()` parses `zellij
+--version` and returns `false` for anything below 0.44.1, so the Zellij
+backend is silently skipped rather than failing at pane-creation time.
+Version 0.44.0 in particular lacks `new-pane --tab-id`, which the
+`current-tab` mode needs to target the parent OpenCode tab directly.
+
+All pane mutations use **direct pane-id targeting** (no `focus-pane`, which is
+not a valid Zellij CLI action, and no `current-tab-info`, which is
+client-bound and fails from pane child processes):
+
+- `rename-pane <name> -p <paneId>` — best-effort pane title; a rename failure
+  never masks a failing attach write
+- `write-chars <chars> -p <paneId>` — writes the `opencode attach` command and
+  a trailing newline directly into the target pane; if either write fails the
+  pane reuse is reported as a failure
+- `list-panes --json --tab --all` — stable `tab_id` per pane, used to map the
+  parent `ZELLIJ_PANE_ID` to its tab
+- `new-pane --tab-id <id> --direction <dir>` — creates a pane in a specific
+  tab; if Zellij silently drops a `--direction` split (a crowded tab — up to
+  ~4 stacked panes — returns exit 0 but no pane id), the create is retried
+  once without the direction hint, letting Zellij place the pane in the
+  largest free space
+
+In `agent-tab` mode the parent tab (from `ZELLIJ_PANE_ID`) is restored after a
+pane is created in the `opencode-agents` tab, and also immediately after the
+tab is first created (`new-tab` moves client focus to the new tab). If the
+parent tab cannot be located, focus stays in the agent tab rather than
+guessing a tab id.
+
+Pane creation sequences are serialized per session: concurrent sub-agent
+starts are queued so tab/focus-mutating actions (new-tab, go-to-tab-by-id,
+new-pane) never interleave — a cross-tab create cannot race another create's
+focus restore.
 
 ### Legacy tmux config
 

+ 55 - 11
src/multiplexer/zellij/codemap.md

@@ -13,11 +13,20 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 
 #### ZellijMultiplexer Class
 - Implements `Multiplexer` interface with `type = 'zellij'`
-- Manages Zellij binary discovery and availability checks
+- Manages Zellij binary discovery, availability checks, and version gating
 - Handles two operational modes via `paneMode`:
   - `'agent-tab'` (default): Creates dedicated "opencode-agents" tab
   - `'current-tab'`: Creates panes in user's current tab
 
+#### Version Gating
+- `isAvailable()` runs `zellij --version` and requires Zellij >= 0.44.1
+- Older releases (or unparsable version output) make `isAvailable()` return
+  `false`, so the backend is silently skipped
+- Required because the adapter relies on stable pane-id targeting that only
+  exists in 0.44.1+: `rename-pane <name> -p <paneId>`,
+  `write-chars <chars> -p <paneId>`, `list-panes --json --tab --all` with
+  stable `tab_id`, and `new-pane --tab-id` for cross-tab creation
+
 #### Session Management
 - **Pane Creation**: Uses `spawnPane()` to create new panes with OpenCode attach commands
 - **Tab Management**: Ensures "opencode-agents" tab exists, tracks tab/pane IDs
@@ -40,13 +49,24 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 ```
 1. Plugin loads → ZellijMultiplexer instantiated with layout='main-vertical'
 2. First sub-agent session:
-   - ensureAgentTab() creates "opencode-agents" tab if not exists
-   - runInPane() focuses default pane and writes OpenCode attach command
+   - ensureAgentTab() creates "opencode-agents" tab if not exists; `new-tab`
+     moves client focus to the new tab, so a freshly created agent tab is
+     immediately followed by go-to-tab-by-id back to the parent tab
+   - runInPane() renames the default pane and writes the OpenCode attach
+     command via direct pane-id targeting (rename-pane/write-chars with
+     `-p <paneId>`; no focus-pane, which is invalid CLI syntax)
    - firstPaneUsed flag set to true
 3. Subsequent sub-agent sessions:
    - createPaneInAgentTab() creates new pane in agent tab
-   - Switches to agent tab, creates pane, switches back to original tab
-4. Session completion:
+   - Switches to agent tab, creates pane, switches back to the parent tab
+     (resolved from the parent pane's ZELLIJ_PANE_ID)
+   - If `--direction` new-pane is silently dropped (crowded tab), the create
+     is retried once without the direction hint
+4. All spawnPane creation sequences are serialized through a promise chain
+   (`paneOpsChain`): concurrent sub-agent starts cannot interleave
+   tab/focus-mutating actions (new-tab, go-to-tab-by-id, new-pane), so a
+   cross-tab create cannot race another create's focus restore
+5. Session completion:
    - closePane() sends Ctrl+C → delay → kill-pane
    - Pane removed from Zellij workspace
 ```
@@ -64,9 +84,15 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 1. findTabByName() uses Zellij's list-tabs action
    - Tries JSON output first (--json flag)
    - Falls back to text parsing if JSON unavailable
-2. getCurrentTabId() queries current-tab-info --json
-3. getFirstPaneInTab() / findTabIdForPane() use listPanesJson() (list-panes --json --tab --all) filtered by tab_id
-4. findTabIdForPane() correlates pane IDs with tab IDs for parent tab tracking
+2. isAvailable() gates on `zellij --version` >= 0.44.1
+3. getParentTabId() resolves the parent tab from ZELLIJ_PANE_ID via
+   findTabIdForPane(); only successful lookups are cached — a failed query is
+   re-tried on the next spawn so a transient list-panes failure is not
+   permanently treated as "no parent tab". current-tab-info is NOT used: it is
+   client-bound and fails from pane child processes
+4. getFirstPaneInTab() / findTabIdForPane() use listPanesJson()
+   (list-panes --json --tab --all) filtered by tab_id
+5. findTabIdForPane() correlates pane IDs with tab IDs for parent tab tracking
 ```
 
 ## Integration Points
@@ -83,12 +109,22 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 - **Session Lifecycle**: MultiplexerSessionManager coordinates pane creation/cleanup with session events
 
 ### Environment
-- **ZELLIJ_PANE_ID**: Used to detect parent pane location when in current-tab mode
-- **Zellij Actions**: All communication via Zellij's CLI action system (new-tab, new-pane, close-pane, etc.)
+- **ZELLIJ_PANE_ID**: Used to locate the parent OpenCode pane's tab; drives
+  `current-tab` targeting and the `agent-tab` restore step
+- **Zellij Actions**: All communication via Zellij's CLI action system
+  (new-tab, new-pane, rename-pane, write-chars, close-pane, etc.); pane
+  mutations use direct pane-id targeting (`-p <paneId>`) instead of focus
+  switching
 
 ### Error Handling
 - Graceful degradation: Returns `{ success: false }` on failures
+- Crowded-split fallback: a `--direction` new-pane that is silently dropped
+  (exit 0, no `terminal_*` id) is retried once without the direction hint,
+  letting Zellij place the pane in the largest free space
+- Write failures in runInPane() return `false` — never a silent success
+- Rename failures are best-effort and do not mask attach write failures
 - Tab/pane discovery falls back to text parsing if JSON unavailable
+- Unresolvable parent tab → safe degradation, never a guessed tab id
 - Layout changes are no-op after pane creation (Zellij doesn't support dynamic layout rebalancing)
 
 ## Key Implementation Details
@@ -107,7 +143,10 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 - `agentTabId`: Caches the "opencode-agents" tab ID after creation
 - `firstPaneId`: Stores the initial pane ID for first sub-agent reuse
 - `firstPaneUsed`: Boolean flag prevents duplicate first pane usage
-- `parentTabId`: Caches parent tab ID for current-tab mode optimization
+- `parentTabId` / `parentTabResolved`: Caches the parent tab ID after a
+  successful lookup only; failed lookups are retried on the next spawn
+- `paneOpsChain`: Promise chain serializing spawnPane creation sequences
+  (tab/focus-mutating actions) so concurrent spawns cannot race
 
 ### Graceful Shutdown Sequence
 ```typescript
@@ -128,6 +167,11 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 - No polling; relies on Zellij's event-driven pane/tab management
 
 ## Limitations
+- Requires Zellij >= 0.44.1 (older versions make `isAvailable()` return false;
+  0.44.0 lacks `new-pane --tab-id`)
+- Zellij silently drops `--direction` splits beyond ~4 stacked panes; the
+  adapter falls back to an undirected create, which still succeeds but uses
+  Zellij's own largest-free-space placement
 - Zellij doesn't support exact main pane sizing like tmux
 - Layout configuration only affects future pane creation directions
 - Requires Zellij to be installed and in PATH

+ 707 - 54
src/multiplexer/zellij/index.test.ts

@@ -55,6 +55,28 @@ function commands(): string[][] {
   return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
 }
 
+/**
+ * Install the standard success mock set (binary found, supported version,
+ * list-panes with the parent pane in tab 0, new-pane emitting a valid id).
+ */
+function mockStandardImpl(version: string): void {
+  crossSpawnMock.mockImplementation((command: string[]) => {
+    if (command[0] === 'which' || command[0] === 'where') {
+      return createSpawnResult(0, '/usr/bin/zellij\n');
+    }
+    if (command.includes('--version')) {
+      return createSpawnResult(0, `zellij ${version}\n`);
+    }
+    if (command.includes('list-panes')) {
+      return createSpawnResult(0, createPaneListJson());
+    }
+    if (command.includes('new-pane')) {
+      return createSpawnResult(0, 'terminal_2\n');
+    }
+    return createSpawnResult();
+  });
+}
+
 async function spawnSecondAgentTabPane(
   layout: 'main-vertical' | 'main-horizontal' | 'tiled',
 ): Promise<string[] | undefined> {
@@ -62,18 +84,18 @@ async function spawnSecondAgentTabPane(
   const zellij = new ZellijMultiplexer(layout, 60, 'agent-tab');
 
   crossSpawnMock.mockImplementation((command: string[]) => {
-    if (command[0] === 'which') {
+    if (command[0] === 'which' || command[0] === 'where') {
       return createSpawnResult(0, '/usr/bin/zellij\n');
     }
+    if (command.includes('--version')) {
+      return createSpawnResult(0, 'zellij 0.44.3\n');
+    }
     if (command.includes('list-tabs')) {
       return createSpawnResult(
         0,
         JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
       );
     }
-    if (command.includes('current-tab-info')) {
-      return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
-    }
     if (command.includes('list-panes') && command.includes('--json')) {
       return createSpawnResult(
         0,
@@ -115,18 +137,7 @@ describe('ZellijMultiplexer', () => {
     process.env.ZELLIJ_PANE_ID = '0';
 
     crossSpawnMock.mockReset();
-    crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
-        return createSpawnResult(0, '/usr/bin/zellij\n');
-      }
-      if (command.includes('list-panes')) {
-        return createSpawnResult(0, createPaneListJson());
-      }
-      if (command.includes('new-pane')) {
-        return createSpawnResult(0, 'terminal_2\n');
-      }
-      return createSpawnResult();
-    });
+    mockStandardImpl('0.44.3');
   });
 
   afterEach(() => {
@@ -134,6 +145,134 @@ describe('ZellijMultiplexer', () => {
     process.env.ZELLIJ_PANE_ID = originalZellijPaneId;
   });
 
+  describe('version gate', () => {
+    test('isAvailable is false for zellij older than 0.44.1', async () => {
+      mockStandardImpl('0.43.1');
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Old zellij worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: false });
+      // Only binary discovery ran; no zellij actions were attempted.
+      expect(commands().some((command) => command.includes('action'))).toBe(
+        false,
+      );
+    });
+
+    test('isAvailable is false for zellij 0.44.0 (new-pane --tab-id needs 0.44.1)', async () => {
+      mockStandardImpl('0.44.0');
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Boundary worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: false });
+      // Only binary discovery ran; no zellij actions were attempted.
+      expect(commands().some((command) => command.includes('action'))).toBe(
+        false,
+      );
+    });
+
+    test('isAvailable is true for the 0.44.1 boundary release', async () => {
+      mockStandardImpl('0.44.1');
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Boundary worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+    });
+
+    test('isAvailable is true for zellij 0.44.3', async () => {
+      mockStandardImpl('0.44.3');
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Modern worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+    });
+
+    test('isAvailable is false when version output cannot be parsed', async () => {
+      crossSpawnMock.mockImplementation((command: string[]) => {
+        if (command[0] === 'which' || command[0] === 'where') {
+          return createSpawnResult(0, '/usr/bin/zellij\n');
+        }
+        if (command.includes('--version')) {
+          return createSpawnResult(0, 'zellij version unknown\n');
+        }
+        return createSpawnResult();
+      });
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Unknown worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: false });
+      expect(commands().some((command) => command.includes('action'))).toBe(
+        false,
+      );
+    });
+
+    test('isAvailable is false when the version probe fails', async () => {
+      crossSpawnMock.mockImplementation((command: string[]) => {
+        if (command[0] === 'which' || command[0] === 'where') {
+          return createSpawnResult(0, '/usr/bin/zellij\n');
+        }
+        if (command.includes('--version')) {
+          return createSpawnResult(1, '', 'probe failed');
+        }
+        return createSpawnResult();
+      });
+
+      const { ZellijMultiplexer } = await importFreshZellij();
+      const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+      const result = await zellij.spawnPane(
+        'session-1',
+        'Probe failure worker',
+        'http://localhost:4096',
+        '/repo',
+      );
+
+      expect(result).toEqual({ success: false });
+      expect(commands().some((command) => command.includes('action'))).toBe(
+        false,
+      );
+    });
+  });
+
   test('current-tab mode spawns a pane in the parent OpenCode tab', async () => {
     const { ZellijMultiplexer } = await importFreshZellij();
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
@@ -181,9 +320,12 @@ describe('ZellijMultiplexer', () => {
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-panes')) {
         return createSpawnResult(0, createPaneListJson());
       }
@@ -203,19 +345,158 @@ describe('ZellijMultiplexer', () => {
     expect(result).toEqual({ success: false });
   });
 
-  test('current-tab mode targets the parent OpenCode tab even when another tab is focused', async () => {
+  test('new-pane retries without --direction when a directed create is silently dropped', async () => {
     const { ZellijMultiplexer } = await importFreshZellij();
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+    let newPaneCalls = 0;
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-panes')) {
         return createSpawnResult(0, createPaneListJson());
       }
-      if (command.includes('current-tab-info')) {
-        return createSpawnResult(0, JSON.stringify({ tab_id: 1 }));
+      if (command.includes('new-pane')) {
+        newPaneCalls++;
+        if (newPaneCalls === 1) {
+          // Zellij drops the split silently: exit 0, no terminal_ id.
+          return createSpawnResult(0, '');
+        }
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+
+    const newPaneCmds = commands().filter((c) => c.includes('new-pane'));
+    expect(newPaneCmds).toHaveLength(2);
+    expect(newPaneCmds[0]).toContain('--direction');
+    // The fallback keeps the pane name, close-on-exit, and the command part,
+    // but drops the direction hint so Zellij picks the largest free space.
+    expect(newPaneCmds[1]).not.toContain('--direction');
+    expect(newPaneCmds[1]).toContain('--name');
+    expect(newPaneCmds[1]).toContain('--close-on-exit');
+    expect(newPaneCmds[1].join(' ')).toContain('opencode attach');
+  });
+
+  test('new-pane reports failure when both the directed create and the fallback fail', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson());
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'plugin_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+
+    const newPaneCmds = commands().filter((c) => c.includes('new-pane'));
+    expect(newPaneCmds).toHaveLength(2);
+    expect(newPaneCmds[0]).toContain('--direction');
+    expect(newPaneCmds[1]).not.toContain('--direction');
+  });
+
+  test('agent-tab mode retries new-pane without --direction after a crowded split', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+    let newPaneCalls = 0;
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('list-panes') && command.includes('--json')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 7, is_plugin: false, tab_id: 5 },
+          ]),
+        );
+      }
+      if (command.includes('new-pane')) {
+        newPaneCalls++;
+        if (newPaneCalls === 1) return createSpawnResult(0, '');
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    // First spawn reuses the agent tab's default pane via write-chars.
+    await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    // Second spawn creates a new pane, hitting the crowded-split fallback.
+    const result = await zellij.spawnPane(
+      'session-2',
+      'Second agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'terminal_8' });
+
+    const newPaneCmds = commands().filter((c) => c.includes('new-pane'));
+    expect(newPaneCmds).toHaveLength(2);
+    expect(newPaneCmds[0]).toContain('--direction');
+    expect(newPaneCmds[1]).not.toContain('--direction');
+  });
+
+  test('current-tab mode targets the parent OpenCode tab even when another tab is focused', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson());
       }
       if (command.includes('new-pane')) {
         return createSpawnResult(0, 'terminal_2\n');
@@ -262,27 +543,27 @@ describe('ZellijMultiplexer', () => {
     expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('0');
   });
 
-  test('current-tab mode falls back to the focused tab if parent tab lookup fails', async () => {
+  test('current-tab mode omits --tab-id when the parent tab lookup fails', async () => {
     const { ZellijMultiplexer } = await importFreshZellij();
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-panes')) {
         return createSpawnResult(1, '', 'list failed');
       }
-      if (command.includes('current-tab-info')) {
-        return createSpawnResult(0, JSON.stringify({ tab_id: 1 }));
-      }
       if (command.includes('new-pane')) {
         return createSpawnResult(0, 'terminal_2\n');
       }
       return createSpawnResult();
     });
 
-    await zellij.spawnPane(
+    const result = await zellij.spawnPane(
       'session-1',
       'Current tab worker',
       'http://localhost:4096',
@@ -292,26 +573,80 @@ describe('ZellijMultiplexer', () => {
     const newPaneCommand = commands().find((command) =>
       command.includes('new-pane'),
     );
-    const tabIdArgIndex = newPaneCommand?.indexOf('--tab-id') ?? -1;
 
-    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
-    expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('1');
+    // No tab is guessed: the pane is created in whatever tab Zellij has
+    // focused, which is Zellij's own default rather than a guess by us.
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+    expect(newPaneCommand).toBeDefined();
+    expect(newPaneCommand).not.toContain('--tab-id');
   });
 
-  test('current-tab mode caches the fallback focused tab after parent tab lookup fails', async () => {
+  test('current-tab mode re-queries a failed parent tab lookup on the next spawn', async () => {
     const { ZellijMultiplexer } = await importFreshZellij();
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
-    let currentTabId = 1;
+    let listPanesCalls = 0;
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-panes')) {
+        listPanesCalls++;
         return createSpawnResult(1, '', 'list failed');
       }
-      if (command.includes('current-tab-info')) {
-        return createSpawnResult(0, JSON.stringify({ tab_id: currentTabId++ }));
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    await zellij.spawnPane(
+      'session-2',
+      'Current tab worker 2',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommands = commands().filter((command) =>
+      command.includes('new-pane'),
+    );
+
+    // A failed lookup is NOT cached as permanent null: the second spawn
+    // queries again (and fails again, so still no --tab-id).
+    expect(listPanesCalls).toBe(2);
+    expect(newPaneCommands).toHaveLength(2);
+    for (const command of newPaneCommands) {
+      expect(command).not.toContain('--tab-id');
+    }
+  });
+
+  test('a transient parent tab lookup failure is retried and cached once it succeeds', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+    let listPanesCalls = 0;
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-panes')) {
+        listPanesCalls++;
+        if (listPanesCalls === 1) {
+          return createSpawnResult(1, '', 'list failed');
+        }
+        return createSpawnResult(0, createPaneListJson());
       }
       if (command.includes('new-pane')) {
         return createSpawnResult(0, 'terminal_2\n');
@@ -331,17 +666,29 @@ describe('ZellijMultiplexer', () => {
       'http://localhost:4096',
       '/repo',
     );
+    await zellij.spawnPane(
+      'session-3',
+      'Current tab worker 3',
+      'http://localhost:4096',
+      '/repo',
+    );
 
     const newPaneCommands = commands().filter((command) =>
       command.includes('new-pane'),
     );
 
-    expect(
-      newPaneCommands.map((command) => {
-        const tabIdArgIndex = command.indexOf('--tab-id');
-        return command[tabIdArgIndex + 1];
-      }),
-    ).toEqual(['1', '1']);
+    expect(listPanesCalls).toBe(2);
+    expect(newPaneCommands).toHaveLength(3);
+    // First spawn: lookup failed -> no --tab-id.
+    expect(newPaneCommands[0]).not.toContain('--tab-id');
+    // Second spawn: lookup succeeded -> targeted the parent tab.
+    const tabIdArgIndex = newPaneCommands[1].indexOf('--tab-id');
+    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommands[1][tabIdArgIndex + 1]).toBe('0');
+    // Third spawn: the successful lookup is cached, no re-query.
+    const tabIdArgIndex3 = newPaneCommands[2].indexOf('--tab-id');
+    expect(tabIdArgIndex3).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommands[2][tabIdArgIndex3 + 1]).toBe('0');
   });
 
   test('main-horizontal layout opens current-tab panes down', async () => {
@@ -398,6 +745,75 @@ describe('ZellijMultiplexer', () => {
     expect(newPaneCommand).not.toContain('--direction');
   });
 
+  test('concurrent spawnPane calls are serialized through the pane-op queue', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    let releaseFirstNewPane!: () => void;
+    const firstNewPaneGate = new Promise<void>((resolve) => {
+      releaseFirstNewPane = resolve;
+    });
+    const order: string[] = [];
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-panes')) {
+        order.push('list');
+        return createSpawnResult(0, createPaneListJson());
+      }
+      if (command.includes('new-pane')) {
+        order.push('new-pane');
+        if (order.filter((entry) => entry === 'new-pane').length === 1) {
+          return {
+            ...createSpawnResult(0, 'terminal_2\n'),
+            exited: firstNewPaneGate.then(() => 0),
+          };
+        }
+        return createSpawnResult(0, 'terminal_3\n');
+      }
+      return createSpawnResult();
+    });
+
+    const first = zellij.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    const second = zellij.spawnPane(
+      'session-2',
+      'Second worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    // Flush microtasks deterministically. The first spawn must reach its
+    // (gated) new-pane call while the second spawn has not started anything:
+    // the whole tab/focus-mutating sequence is queued.
+    for (let i = 0; i < 32; i++) {
+      await Promise.resolve();
+    }
+
+    expect(order).toEqual(['list', 'new-pane']);
+
+    releaseFirstNewPane();
+    const [firstResult, secondResult] = await Promise.all([first, second]);
+
+    expect(firstResult).toEqual({ success: true, paneId: 'terminal_2' });
+    expect(secondResult).toEqual({ success: true, paneId: 'terminal_3' });
+
+    const newPaneCmds = commands().filter((c) => c.includes('new-pane'));
+    expect(newPaneCmds).toHaveLength(2);
+    // The second spawn's new-pane ran only after the first completed.
+    expect(newPaneCmds[0].join(' ')).toContain("'session-1'");
+    expect(newPaneCmds[1].join(' ')).toContain("'session-2'");
+  });
+
   test('tiled layout uses zellij native current-tab pane placement', async () => {
     const { ZellijMultiplexer } = await importFreshZellij();
     const zellij = new ZellijMultiplexer('tiled', 60, 'current-tab');
@@ -443,19 +859,19 @@ describe('ZellijMultiplexer', () => {
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-tabs')) {
         return createSpawnResult(
           0,
           JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
         );
       }
-      if (command.includes('current-tab-info')) {
-        return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
-      }
-      // getFirstPaneInTab: list-panes with --json --tab --all
+      // getFirstPaneInTab / findTabIdForPane: list-panes with --json --tab --all
       if (command.includes('--json') && command.includes('--tab')) {
         return createSpawnResult(
           0,
@@ -486,10 +902,39 @@ describe('ZellijMultiplexer', () => {
     const newPaneCmds = allCommands.filter((c) => c.includes('new-pane'));
     expect(newPaneCmds).toHaveLength(0);
 
-    // Should focus the pane in tab 5 (terminal_7), not the one in tab 0 (terminal_0)
-    const focusPaneCmd = allCommands.find((c) => c.includes('focus-pane'));
-    expect(focusPaneCmd).toBeDefined();
-    expect(focusPaneCmd).toEqual(expect.arrayContaining(['terminal_7']));
+    // Should target the pane in tab 5 (terminal_7), not the one in tab 0,
+    // via direct pane-id targeting — and never use focus-pane.
+    expect(allCommands.some((c) => c.includes('focus-pane'))).toBe(false);
+
+    const renameCmd = allCommands.find((c) => c.includes('rename-pane'));
+    expect(renameCmd).toBeDefined();
+    expect(renameCmd).toEqual([
+      '/usr/bin/zellij',
+      'action',
+      'rename-pane',
+      'First agent worker',
+      '-p',
+      'terminal_7',
+    ]);
+
+    const writeCharsCmds = allCommands.filter((c) => c.includes('write-chars'));
+    expect(writeCharsCmds).toHaveLength(2);
+    expect(writeCharsCmds[0][0]).toBe('/usr/bin/zellij');
+    expect(writeCharsCmds[0][1]).toBe('action');
+    expect(writeCharsCmds[0][2]).toBe('write-chars');
+    expect(writeCharsCmds[0][3]).toContain('sh -lc');
+    expect(writeCharsCmds[0][3]).toContain('opencode attach');
+    expect(writeCharsCmds[0][3]).toContain("'http://localhost:4096'");
+    expect(writeCharsCmds[0][4]).toBe('-p');
+    expect(writeCharsCmds[0][5]).toBe('terminal_7');
+    expect(writeCharsCmds[1]).toEqual([
+      '/usr/bin/zellij',
+      'action',
+      'write-chars',
+      '\n',
+      '-p',
+      'terminal_7',
+    ]);
   });
 
   test('getFirstPaneInTab null falls through to new-pane', async () => {
@@ -497,18 +942,18 @@ describe('ZellijMultiplexer', () => {
     const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') {
+      if (command[0] === 'which' || command[0] === 'where') {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
       if (command.includes('list-tabs')) {
         return createSpawnResult(
           0,
           JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
         );
       }
-      if (command.includes('current-tab-info')) {
-        return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
-      }
       // getFirstPaneInTab: only main tab (tab 0) has panes, agent tab (5) has none
       if (command.includes('--json') && command.includes('--tab')) {
         return createSpawnResult(
@@ -543,4 +988,212 @@ describe('ZellijMultiplexer', () => {
     const writeCharsCmds = allCommands.filter((c) => c.includes('write-chars'));
     expect(writeCharsCmds).toHaveLength(0);
   });
+
+  test('agent-tab mode ignores a failed rename when the attach write succeeds', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('list-panes') && command.includes('--json')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 7, is_plugin: false, tab_id: 5 },
+          ]),
+        );
+      }
+      if (command.includes('rename-pane')) {
+        return createSpawnResult(1, '', 'rename failed');
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    // Rename failure is cosmetic; the attach writes still launched the agent.
+    expect(result).toEqual({ success: true, paneId: 'terminal_7' });
+    expect(commands().some((c) => c.includes('new-pane'))).toBe(false);
+  });
+
+  test('agent-tab mode falls through to a new pane when the attach write fails', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('list-panes') && command.includes('--json')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 7, is_plugin: false, tab_id: 5 },
+          ]),
+        );
+      }
+      if (command.includes('write-chars')) {
+        return createSpawnResult(1, '', 'write failed');
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    // The failed write must be reported as a reuse failure (never silently
+    // treated as success), so the pane is created fresh in the agent tab.
+    expect(result).toEqual({ success: true, paneId: 'terminal_8' });
+    expect(commands().some((c) => c.includes('focus-pane'))).toBe(false);
+  });
+
+  test('agent-tab mode restores the parent tab after creating a pane', async () => {
+    await spawnSecondAgentTabPane('main-vertical');
+
+    const goToTabCmds = commands().filter((c) => c.includes('go-to-tab-by-id'));
+
+    // Switch to the agent tab (5), then back to the parent tab (0).
+    expect(goToTabCmds).toHaveLength(2);
+    expect(goToTabCmds[0][3]).toBe('5');
+    expect(goToTabCmds[1][3]).toBe('0');
+  });
+
+  test('agent-tab mode does not restore a tab when the parent tab is unknown', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('list-panes') && command.includes('--json')) {
+        return createSpawnResult(1, '', 'list failed');
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    // First spawn: agent tab exists but has no panes -> falls through to
+    // new-pane directly (no go-to-tab needed for the first reuse attempt).
+    await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const goToTabCmds = commands().filter((c) => c.includes('go-to-tab-by-id'));
+
+    // The agent tab switch happened, but the parent tab could not be located
+    // so no restore command is emitted — focus is left in the agent tab
+    // rather than guessing a tab id.
+    expect(goToTabCmds.map((c) => c[3])).toEqual(['5']);
+  });
+
+  test('ensureAgentTab restores the parent tab after creating the opencode-agents tab', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+    let tabCreated = false;
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' || command[0] === 'where') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('--version')) {
+        return createSpawnResult(0, 'zellij 0.44.3\n');
+      }
+      if (command.includes('new-tab')) {
+        tabCreated = true;
+        return createSpawnResult();
+      }
+      if (command.includes('list-tabs')) {
+        if (tabCreated) {
+          return createSpawnResult(
+            0,
+            JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+          );
+        }
+        return createSpawnResult(0, JSON.stringify([]));
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 7, is_plugin: false, tab_id: 5 },
+          ]),
+        );
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'terminal_7' });
+
+    // The tab was created (no pre-existing opencode-agents tab), so new-tab
+    // moved focus to it — and the adapter switched straight back to the
+    // parent tab (0) before launching the first agent.
+    const newTabCmds = commands().filter((c) => c.includes('new-tab'));
+    expect(newTabCmds).toHaveLength(1);
+
+    const goToTabCmds = commands().filter((c) => c.includes('go-to-tab-by-id'));
+    expect(goToTabCmds.map((c) => c[3])).toEqual(['0']);
+  });
 });

+ 224 - 132
src/multiplexer/zellij/index.ts

@@ -3,10 +3,20 @@
  *
  * Creates panes for sub-agent sessions in Zellij.
  *
+ * Requires Zellij >= 0.44.1: `isAvailable()` parses `zellij --version` and
+ * rejects older releases whose CLI lacks the stable pane-id targeting used
+ * here (`rename-pane <name> -p <paneId>`, `write-chars <chars> -p <paneId>`,
+ * `list-panes --json --tab --all` with stable `tab_id`, and
+ * `new-pane --tab-id` for cross-tab targeting; `--tab-id` only exists in
+ * 0.44.1+). No `focus-pane` or `current-tab-info` calls are made — the former
+ * is invalid CLI syntax and the latter is client-bound and fails from pane
+ * child processes.
+ *
  * The default mode creates a dedicated "opencode-agents" tab:
  * - First sub-agent uses the default pane from new-tab
  * - Subsequent sub-agents create new panes
- * - User stays in their original tab
+ * - User stays in their original tab (resolved from the parent pane's
+ *   ZELLIJ_PANE_ID via list-panes)
  *
  * The optional "current-tab" mode creates panes in the tab containing the
  * parent OpenCode pane instead.
@@ -46,8 +56,16 @@ export class ZellijMultiplexer implements Multiplexer {
   private firstPaneId: string | null = null;
   private firstPaneUsed = false;
   private parentTabId: string | null = null;
+  private parentTabResolved = false;
   private readonly parentPaneId = process.env.ZELLIJ_PANE_ID;
   private readonly paneDirection: ZellijPaneDirection | null;
+  /**
+   * Serializes pane-creation sequences that may switch Zellij tabs or move
+   * client focus (new-tab, go-to-tab-by-id, new-pane). Concurrent spawns are
+   * chained so a cross-tab create cannot race another create's focus restore.
+   * Read-only queries (list-panes/list-tabs) never go through this queue.
+   */
+  private paneOpsChain: Promise<void> = Promise.resolve();
 
   constructor(
     layout: MultiplexerLayout = 'main-vertical',
@@ -64,9 +82,37 @@ export class ZellijMultiplexer implements Multiplexer {
     if (this.hasChecked) {
       return this.binaryPath !== null;
     }
-    this.binaryPath = await findBinary('zellij');
     this.hasChecked = true;
-    return this.binaryPath !== null;
+    const binaryPath = await findBinary('zellij');
+    if (binaryPath && (await this.hasSupportedVersion(binaryPath))) {
+      this.binaryPath = binaryPath;
+      return true;
+    }
+    this.binaryPath = null;
+    return false;
+  }
+
+  /**
+   * Parse and gate on the installed Zellij version. The adapter relies on
+   * stable pane-id targeting that only exists in Zellij >= 0.44.1; older
+   * releases (or unparsable version output) make the backend unavailable.
+   */
+  private async hasSupportedVersion(path: string): Promise<boolean> {
+    const version = await this.readVersion(path);
+    return version !== null && isSupportedZellijVersion(version);
+  }
+
+  private async readVersion(path: string): Promise<ZellijVersion | null> {
+    try {
+      const proc = crossSpawn([path, '--version'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      if ((await proc.exited) !== 0) return null;
+      return parseZellijVersion(await proc.stdout());
+    } catch {
+      return null;
+    }
   }
 
   isInsideSession(): boolean {
@@ -78,6 +124,27 @@ export class ZellijMultiplexer implements Multiplexer {
     description: string,
     serverUrl: string,
     directory: string,
+  ): Promise<PaneResult> {
+    // The tab/focus-mutating creation sequence is queued so concurrent
+    // spawnPane calls cannot interleave (e.g. a cross-tab new-pane racing
+    // another create's focus restore). Binary discovery and availability
+    // probing happen inside the unlocked body too, which is fine: they are
+    // cached after the first call.
+    const run = this.paneOpsChain.then(() =>
+      this.spawnPaneUnlocked(sessionId, description, serverUrl, directory),
+    );
+    this.paneOpsChain = run.then(
+      () => undefined,
+      () => undefined,
+    );
+    return run;
+  }
+
+  private async spawnPaneUnlocked(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+    directory: string,
   ): Promise<PaneResult> {
     const zellij = await this.getBinary();
     if (!zellij) return { success: false };
@@ -148,58 +215,34 @@ export class ZellijMultiplexer implements Multiplexer {
     const paneName = description.slice(0, 30).replace(/"/g, '\\"');
     const targetTabId = await this.getParentTabId(zellij);
 
-    const args = [
-      'action',
-      'new-pane',
-      ...this.tabIdArgs(targetTabId),
-      ...this.directionArgs(),
-      '--name',
-      paneName,
-      '--close-on-exit',
-      '--',
-      'sh',
-      '-lc',
-      opencodeCmd,
-    ];
-
-    const proc = crossSpawn([zellij, ...args], {
-      stdout: 'pipe',
-      stderr: 'pipe',
+    return this.runNewPaneWithFallback(zellij, paneName, opencodeCmd, {
+      tabIdArgs: this.tabIdArgs(targetTabId),
     });
-
-    const exitCode = await proc.exited;
-    const stdout = await proc.stdout();
-    const paneId = stdout.trim();
-
-    if (exitCode === 0 && paneId?.startsWith('terminal_')) {
-      return { success: true, paneId };
-    }
-    return { success: false };
   }
 
-  private async createPaneInAgentTab(
+  /**
+   * Run `new-pane`, retrying once without the direction hint on failure.
+   *
+   * Zellij silently drops a `--direction` split once a tab is crowded (exit
+   * code 0 but no `terminal_*` id on stdout), so a failed directed create is
+   * retried without `--direction`, which lets Zellij place the pane in the
+   * largest free space. The retry keeps `--name`, `--close-on-exit`, and the
+   * command part — only the direction hint is dropped. Two failures (or one
+   * failure with no direction configured) report `{ success: false }`.
+   */
+  private async runNewPaneWithFallback(
     zellij: string,
-    sessionId: string,
-    serverUrl: string,
-    directory: string,
-    description: string,
+    paneName: string,
+    opencodeCmd: string,
+    opts: { tabIdArgs: string[] },
   ): Promise<PaneResult> {
-    const opencodeCmd = buildOpencodeAttachCommand(
-      sessionId,
-      serverUrl,
-      directory,
-    );
-    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 direction = this.directionArgs();
+    const runOnce = async (directionArgs: string[]): Promise<PaneResult> => {
       const args = [
         'action',
         'new-pane',
-        ...this.directionArgs(),
+        ...opts.tabIdArgs,
+        ...directionArgs,
         '--name',
         paneName,
         '--close-on-exit',
@@ -223,15 +266,44 @@ export class ZellijMultiplexer implements Multiplexer {
         return { success: true, paneId };
       }
       return { success: false };
+    };
+
+    const first = await runOnce(direction);
+    if (first.success) return first;
+    // Retry only when a direction was actually applied; an undirected create
+    // already uses Zellij's free-space placement and would just repeat.
+    if (direction.length === 0) return first;
+    return runOnce([]);
+  }
+
+  private async createPaneInAgentTab(
+    zellij: string,
+    sessionId: string,
+    serverUrl: string,
+    directory: string,
+    description: string,
+  ): Promise<PaneResult> {
+    const opencodeCmd = buildOpencodeAttachCommand(
+      sessionId,
+      serverUrl,
+      directory,
+    );
+    const paneName = description.slice(0, 30).replace(/"/g, '\\"');
+
+    const parentTabId = await this.getParentTabId(zellij);
+    const inAgentTab = parentTabId === this.agentTabId;
+
+    if (inAgentTab) {
+      // Already in agent tab, create pane directly
+      return this.runNewPaneWithFallback(zellij, paneName, opencodeCmd, {
+        tabIdArgs: [],
+      });
     }
 
     if (!this.agentTabId) {
       return { success: false };
     }
 
-    // Get current tab before switching
-    const originalTab = await this.getCurrentTabId(zellij);
-
     // Switch to agent tab
     await crossSpawn([zellij, 'action', 'go-to-tab-by-id', this.agentTabId], {
       stdout: 'ignore',
@@ -239,32 +311,20 @@ export class ZellijMultiplexer implements Multiplexer {
     }).exited;
 
     // Create pane
-    const args = [
-      'action',
-      'new-pane',
-      ...this.directionArgs(),
-      '--name',
+    const result = await this.runNewPaneWithFallback(
+      zellij,
       paneName,
-      '--close-on-exit',
-      '--',
-      'sh',
-      '-lc',
       opencodeCmd,
-    ];
-
-    const proc = crossSpawn([zellij, ...args], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-
-    const exitCode = await proc.exited;
-    const stdout = await proc.stdout();
-    const paneId = stdout.trim();
+      { tabIdArgs: [] },
+    );
 
-    // Switch back to original tab
-    if (originalTab) {
+    // Switch back to the parent tab (the tab containing the OpenCode pane
+    // that spawned the sub-agent, resolved via ZELLIJ_PANE_ID). If the parent
+    // tab could not be located, leave focus in the agent tab rather than
+    // guessing.
+    if (parentTabId) {
       await crossSpawn(
-        [zellij, 'action', 'go-to-tab-by-id', String(originalTab)],
+        [zellij, 'action', 'go-to-tab-by-id', String(parentTabId)],
         {
           stdout: 'ignore',
           stderr: 'ignore',
@@ -272,11 +332,7 @@ export class ZellijMultiplexer implements Multiplexer {
       ).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 };
+    return result;
   }
 
   private async runInPane(
@@ -294,28 +350,39 @@ export class ZellijMultiplexer implements Multiplexer {
         directory,
       );
 
-      await crossSpawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
-
-      await crossSpawn(
-        [zellij, 'action', 'rename-pane', '--name', description.slice(0, 30)],
+      // Rename is best-effort cosmetics: a rename failure must not mask a
+      // failing attach write, so its exit code is intentionally ignored.
+      const renameProc = crossSpawn(
+        [
+          zellij,
+          'action',
+          'rename-pane',
+          description.slice(0, 30),
+          '-p',
+          paneId,
+        ],
         { stdout: 'ignore', stderr: 'ignore' },
-      ).exited;
+      );
+      await renameProc.exited;
 
-      await crossSpawn(
-        [zellij, 'action', 'write-chars', buildShellLaunchCommand(opencodeCmd)],
-        {
-          stdout: 'ignore',
-          stderr: 'ignore',
-        },
-      ).exited;
+      const writeCmdProc = crossSpawn(
+        [
+          zellij,
+          'action',
+          'write-chars',
+          buildShellLaunchCommand(opencodeCmd),
+          '-p',
+          paneId,
+        ],
+        { stdout: 'ignore', stderr: 'ignore' },
+      );
+      if ((await writeCmdProc.exited) !== 0) return false;
 
-      await crossSpawn([zellij, 'action', 'write-chars', '\n'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
+      const writeNewlineProc = crossSpawn(
+        [zellij, 'action', 'write-chars', '\n', '-p', paneId],
+        { stdout: 'ignore', stderr: 'ignore' },
+      );
+      if ((await writeNewlineProc.exited) !== 0) return false;
 
       return true;
     } catch {
@@ -354,6 +421,23 @@ export class ZellijMultiplexer implements Multiplexer {
 
       // Get the default pane in the new tab
       const firstPane = await this.getFirstPaneInTab(zellij, newTab.tabId);
+
+      // `new-tab` moves the attached client's focus to the new tab. Restore
+      // the parent tab (resolved via ZELLIJ_PANE_ID) so the user stays where
+      // they were, mirroring the restore done after pane creation. If the
+      // parent tab cannot be located, leave focus in the agent tab rather
+      // than guessing.
+      const parentTabId = await this.getParentTabId(zellij);
+      if (parentTabId) {
+        await crossSpawn(
+          [zellij, 'action', 'go-to-tab-by-id', String(parentTabId)],
+          {
+            stdout: 'ignore',
+            stderr: 'ignore',
+          },
+        ).exited;
+      }
+
       return { tabId: newTab.tabId, firstPaneId: firstPane };
     } catch {
       return null;
@@ -452,31 +536,6 @@ export class ZellijMultiplexer implements Multiplexer {
     }
   }
 
-  private async getCurrentTabId(zellij: string): Promise<string | null> {
-    try {
-      const proc = crossSpawn(
-        [zellij, 'action', 'current-tab-info', '--json'],
-        {
-          stdout: 'pipe',
-          stderr: 'pipe',
-        },
-      );
-
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) return null;
-
-      const stdout = await proc.stdout();
-      try {
-        const info = JSON.parse(stdout);
-        return String(info.tab_id);
-      } catch {
-        return null;
-      }
-    } catch {
-      return null;
-    }
-  }
-
   async closePane(paneId: string): Promise<boolean> {
     const zellij = await this.getBinary();
     return gracefulClosePane(zellij, paneId, {
@@ -505,18 +564,20 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 
   private async getParentTabId(zellij: string): Promise<string | null> {
-    if (this.parentTabId) return this.parentTabId;
-
-    if (this.parentPaneId) {
-      const tabId = await this.findTabIdForPane(zellij, this.parentPaneId);
-      if (tabId) {
-        this.parentTabId = tabId;
-        return tabId;
-      }
+    if (this.parentTabResolved) return this.parentTabId;
+    if (!this.parentPaneId) return null;
+
+    const tabId = await this.findTabIdForPane(zellij, this.parentPaneId);
+    // Cache only a successful lookup. A failed query is not cached so a
+    // transient list-panes failure (e.g. early in the session) is retried on
+    // the next spawn instead of being permanently treated as "no parent tab".
+    // `current-tab-info` is deliberately not used: it is client-bound and
+    // fails from pane child processes.
+    if (tabId !== null) {
+      this.parentTabId = tabId;
+      this.parentTabResolved = true;
     }
-
-    this.parentTabId = await this.getCurrentTabId(zellij);
-    return this.parentTabId;
+    return tabId;
   }
 
   private async findTabIdForPane(
@@ -547,6 +608,37 @@ function normalizePaneId(paneId: string): string {
   return paneId.replace(/^terminal_/, '');
 }
 
+interface ZellijVersion {
+  major: number;
+  minor: number;
+  patch: number;
+}
+
+/**
+ * Oldest Zellij release with the stable pane-id targeting this adapter relies
+ * on (`rename-pane <name> -p <paneId>`, `write-chars <chars> -p <paneId>`,
+ * `list-panes --json --tab --all` with stable `tab_id`, and
+ * `new-pane --tab-id` for cross-tab creation, which only exists in 0.44.1+).
+ */
+const MIN_ZELLIJ_VERSION: ZellijVersion = { major: 0, minor: 44, patch: 1 };
+
+function parseZellijVersion(output: string): ZellijVersion | null {
+  const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(output.trim());
+  if (!match) return null;
+  return {
+    major: Number(match[1]),
+    minor: Number(match[2]),
+    patch: match[3] === undefined ? 0 : Number(match[3]),
+  };
+}
+
+function isSupportedZellijVersion(version: ZellijVersion): boolean {
+  const min = MIN_ZELLIJ_VERSION;
+  if (version.major !== min.major) return version.major > min.major;
+  if (version.minor !== min.minor) return version.minor > min.minor;
+  return version.patch >= min.patch;
+}
+
 function getPaneDirection(
   layout: MultiplexerLayout,
 ): ZellijPaneDirection | null {