Sfoglia il codice sorgente

Merge branch 'master' into v2-beta

Alvin 1 mese fa
parent
commit
e5039c8256

+ 9 - 0
.all-contributorsrc

@@ -533,6 +533,15 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "smatheusblu",
+      "name": "Matheus Nogueira Silveira",
+      "avatar_url": "https://avatars.githubusercontent.com/u/5666794?v=4",
+      "profile": "https://github.com/smatheusblu",
+      "contributions": [
+        "code"
+      ]
     }
   ],
   "commitConvention": "angular"

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+github: [alvinunreal]

+ 15 - 1
README.md

@@ -17,6 +17,19 @@
   <p>
     <b>English</b> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.ko-KR.md">한국어</a>
   </p>
+
+  <p><sub>✦ ✦ ✦</sub></p>
+
+## Support the Project 💖
+
+oh-my-opencode-slim is built and maintained in my spare time. If it helps you consider sponsoring my codex subscription.
+
+**Goal: $200/month**
+
+[![Sponsor](https://img.shields.io/github/sponsors/alvinunreal?style=for-the-badge&logo=GitHub-Sponsors&logoColor=EA4AAA&label=Sponsor)](https://github.com/sponsors/alvinunreal)
+
+See live progress + become a sponsor → [GitHub Sponsors](https://github.com/sponsors/alvinunreal)
+
 </div>
 
 ---
@@ -555,7 +568,7 @@ Use this section as a map: start with installation, then jump to features, confi
   <p><sub>Every merged contribution leaves a mark on the realm.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-58-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-59-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -643,6 +656,7 @@ Use this section as a map: start with installation, then jump to features, confi
       <td align="center" valign="top" width="16.66%"><a href="https://khallaf.uk/"><img src="https://avatars.githubusercontent.com/u/51155980?v=4?s=100" width="100px;" alt="Omar Mohamed Khallaf"/><br /><sub><b>Omar Mohamed Khallaf</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=omar-mohamed-khallaf" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/Qesire"><img src="https://avatars.githubusercontent.com/u/102657430?v=4?s=100" width="100px;" alt="Knowingthesea_Qesire"/><br /><sub><b>Knowingthesea_Qesire</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=Qesire" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="http://www.flyinghail.net/"><img src="https://avatars.githubusercontent.com/u/157430?v=4?s=100" width="100px;" alt="FENG Hao"/><br /><sub><b>FENG Hao</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=flyinghail" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/smatheusblu"><img src="https://avatars.githubusercontent.com/u/5666794?v=4?s=100" width="100px;" alt="Matheus Nogueira Silveira"/><br /><sub><b>Matheus Nogueira Silveira</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=smatheusblu" title="Code">💻</a></td>
     </tr>
   </tbody>
 </table>

+ 7 - 2
docs/configuration.md

@@ -108,8 +108,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
-| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
-| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |
+| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij maps `main-vertical` to right and `main-horizontal` to down |
+| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij |
+| `multiplexer.zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `agent-tab` creates/reuses a dedicated `opencode-agents` tab; `current-tab` opens subagents as panes in the tab containing the parent OpenCode pane, falling back to the focused tab if the parent pane cannot be resolved |
 | `divoom.enabled` | boolean | `false` | Enable Divoom Bluetooth display status GIFs for plugin load and delegated agent calls |
 | `divoom.python` | string | Divoom MiniToo bundled Python | Python executable used to run Divoom MiniToo's `divoom_send.py` helper |
 | `divoom.script` | string | Divoom MiniToo `divoom_send.py` | Divoom sender script path |
@@ -172,6 +173,10 @@ With `autoUpdate` set to `false`, this becomes notification-only mode: you'll
 see that a new version is available, but the plugin won't install it
 automatically.
 
+Auto-update never crosses major versions. For example, a 1.x install can
+auto-update to a newer 1.x release, but it won't auto-install 2.x. When a newer
+major is available, the plugin shows a migration command instead.
+
 > Pinned plugin entries in `opencode.json` (for example
 > `"oh-my-opencode-slim@1.0.1"`) are the true version lock. Those stay pinned
 > regardless of `autoUpdate`.

+ 32 - 3
docs/multiplexer-integration.md

@@ -128,15 +128,32 @@ Please analyze this codebase and create a documentation structure.
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
 | `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, or `"none"` |
-| `layout` | string | `"main-vertical"` | Layout preset for tmux only |
+| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij pane directions where possible |
 | `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`) |
+| `zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `"agent-tab"` creates/reuses a dedicated tab; `"current-tab"` opens panes in the tab containing the parent OpenCode pane |
 
 ### Supported Multiplexers
 
 | Multiplexer | Status | Notes |
 |-------------|--------|-------|
 | **Tmux** | ✅ Supported | Full layout control with `main-vertical`, `main-horizontal`, `tiled`, and more |
-| **Zellij** | ✅ Supported | Creates a dedicated `opencode-agents` tab and reuses the default pane |
+| **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 |
+
+**Example: open Zellij subagents in the parent OpenCode tab**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "zellij",
+    "zellij_pane_mode": "current-tab"
+  }
+}
+```
+
+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.
 
 ### Legacy tmux config
 
@@ -158,7 +175,9 @@ This is converted automatically to `multiplexer.type: "tmux"`.
 
 ## Layouts
 
-These layouts apply to **tmux only**:
+Tmux supports full layout control and main pane sizing. Zellij maps only the
+`main-*` layout settings to pane creation directions; exact `main_pane_size`
+rebalancing is tmux-only.
 
 | Layout | Description |
 |--------|-------------|
@@ -168,6 +187,16 @@ These layouts apply to **tmux only**:
 | `even-horizontal` | All panes side by side |
 | `even-vertical` | All panes stacked vertically |
 
+For Zellij:
+
+| Layout | Zellij behavior |
+|--------|-----------------|
+| `main-vertical` | Opens new subagent panes to the right |
+| `main-horizontal` | Opens new subagent panes down |
+| `even-horizontal` | Uses Zellij's native pane placement |
+| `even-vertical` | Uses Zellij's native pane placement |
+| `tiled` | Uses Zellij's native pane placement |
+
 **Example: wide-screen layout**
 
 ```jsonc

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

@@ -413,6 +413,14 @@
           "type": "number",
           "minimum": 20,
           "maximum": 80
+        },
+        "zellij_pane_mode": {
+          "default": "agent-tab",
+          "type": "string",
+          "enum": [
+            "agent-tab",
+            "current-tab"
+          ]
         }
       }
     },

+ 34 - 0
src/config/fallback-chains.ts

@@ -0,0 +1,34 @@
+export function normalizeFallbackChainsForPreset(
+  chains: Record<string, string[] | undefined>,
+  presetName: string | null | undefined,
+): Record<string, string[]> {
+  const normalized: Record<string, string[]> = {};
+
+  for (const [rawKey, chainModels] of Object.entries(chains)) {
+    if (!chainModels?.length) continue;
+
+    const separatorIndex = rawKey.indexOf(':');
+    const hasPresetScope = separatorIndex !== -1;
+    const scopedPreset = hasPresetScope ? rawKey.slice(0, separatorIndex) : '';
+    const agentName = hasPresetScope
+      ? rawKey.slice(separatorIndex + 1)
+      : rawKey;
+
+    if (!agentName) continue;
+    if (hasPresetScope && scopedPreset !== presetName) continue;
+
+    const existing = normalized[agentName] ?? [];
+    const seen = new Set(existing);
+    for (const chainModel of chainModels) {
+      if (seen.has(chainModel)) continue;
+      seen.add(chainModel);
+      existing.push(chainModel);
+    }
+
+    if (existing.length > 0) {
+      normalized[agentName] = existing;
+    }
+  }
+
+  return normalized;
+}

+ 1 - 0
src/config/loader.ts

@@ -417,6 +417,7 @@ function migrateTmuxToMultiplexer(config: PluginConfig): PluginConfig {
         type: 'tmux',
         layout: config.tmux.layout ?? 'main-vertical',
         main_pane_size: config.tmux.main_pane_size ?? 60,
+        zellij_pane_mode: 'agent-tab',
       },
     };
   }

+ 88 - 1
src/config/model-resolution.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from 'bun:test';
 import type { ModelEntry } from '../config/schema';
+import { normalizeFallbackChainsForPreset } from './fallback-chains';
 
 /**
  * Test the model array resolution logic that runs in the config hook.
@@ -79,12 +80,14 @@ describe('fallback.chains merging for foreground agents', () => {
     modelArray?: Array<{ id: string; variant?: string }>;
     currentModel?: string;
     chainModels?: string[];
+    preset?: string;
     fallbackEnabled?: boolean;
   }): string | null {
     const {
       modelArray,
       currentModel,
       chainModels,
+      preset,
       fallbackEnabled = true,
     } = opts;
 
@@ -94,11 +97,18 @@ describe('fallback.chains merging for foreground agents', () => {
       : [];
 
     if (fallbackEnabled && chainModels && chainModels.length > 0) {
+      const normalized = normalizeFallbackChainsForPreset(
+        {
+          [currentModel ?? 'orchestrator']: chainModels,
+        },
+        preset,
+      );
+      const normalizedModels = Object.values(normalized)[0] ?? [];
       if (effectiveArray.length === 0 && currentModel) {
         effectiveArray.push({ id: currentModel });
       }
       const seen = new Set(effectiveArray.map((m) => m.id));
-      for (const chainModel of chainModels) {
+      for (const chainModel of normalizedModels) {
         if (!seen.has(chainModel)) {
           seen.add(chainModel);
           effectiveArray.push({ id: chainModel });
@@ -173,4 +183,81 @@ describe('fallback.chains merging for foreground agents', () => {
     });
     expect(result).toBe('github-copilot/claude-opus-4.6');
   });
+
+  test('normalizes scoped fallback chains for the active preset', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': [
+          'openai/o3',
+          'anthropic/claude-sonnet-4-6',
+        ],
+        'gpt-plus-max:oracle': ['anthropic/claude-opus-4-5'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['openai/o3', 'anthropic/claude-sonnet-4-6'],
+      oracle: ['anthropic/claude-opus-4-5'],
+    });
+  });
+
+  test('ignores scoped fallback chains for other presets', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': ['openai/o3'],
+        orchestrator: ['anthropic/claude-sonnet-4-6'],
+      },
+      'other-preset',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['anthropic/claude-sonnet-4-6'],
+    });
+  });
+
+  test('does not emit scoped keys in normalized output', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': ['openai/o3'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(Object.keys(result).some((key) => key.includes(':'))).toBe(false);
+  });
+
+  test('ignores empty fallback chain agent names', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        '': ['openai/o3'],
+        'gpt-plus-max:': ['anthropic/claude-sonnet-4-6'],
+        orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
+    });
+    expect(result).not.toHaveProperty('');
+  });
+
+  test('runtime preset takes precedence over config preset for scoped fallback chains', () => {
+    const configPreset = 'cheap';
+    const runtimePreset = 'powerful';
+    const activePreset = runtimePreset ?? configPreset ?? null;
+
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'cheap:orchestrator': ['cheap/model'],
+        'powerful:orchestrator': ['powerful/model'],
+      },
+      activePreset,
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['powerful/model'],
+    });
+  });
 });

+ 5 - 0
src/config/schema.ts

@@ -124,6 +124,10 @@ export const MultiplexerLayoutSchema = z.enum([
 
 export type MultiplexerLayout = z.infer<typeof MultiplexerLayoutSchema>;
 
+// Zellij pane placement options
+export const ZellijPaneModeSchema = z.enum(['agent-tab', 'current-tab']);
+export type ZellijPaneMode = z.infer<typeof ZellijPaneModeSchema>;
+
 // Legacy Tmux layout options (for backward compatibility)
 export const TmuxLayoutSchema = MultiplexerLayoutSchema;
 export type TmuxLayout = MultiplexerLayout;
@@ -133,6 +137,7 @@ 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
+  zellij_pane_mode: ZellijPaneModeSchema.default('agent-tab'),
 });
 
 export type MultiplexerConfig = z.infer<typeof MultiplexerConfigSchema>;

+ 257 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -156,4 +156,261 @@ describe('auto-update-checker/checker', () => {
       readSpy.mockRestore();
     });
   });
+
+  describe('getLatestCompatibleVersion', () => {
+    test('selects latest version within current major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '2.0.0',
+          },
+          versions: {
+            '1.1.0': {},
+            '1.1.2': {},
+            '2.0.0': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: '2.0.0',
+        blockedByMajor: true,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('does not report major block when latest is same major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.1.2',
+          },
+          versions: {
+            '1.1.1': {},
+            '1.1.2': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('uses channel tag as blocking major version', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.9.0',
+            beta: '2.0.0-beta.1',
+          },
+          versions: {
+            '1.9.0': {},
+            '2.0.0-beta.1': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.8.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: '2.0.0-beta.1',
+        blockedByMajor: true,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('treats unparseable current version as unsafe for auto-update', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          latest: '2.0.0',
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('workspace:*');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: '2.0.0',
+        blockedByMajor: true,
+        unsafeReason: 'unparseable-current-version',
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('parses range prefixes before checking major compatibility', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.9.0',
+          },
+          versions: {
+            '1.8.0': {},
+            '1.9.0': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('^1.0.0');
+
+      expect(result).toEqual({
+        latestVersion: '1.9.0',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('sorts prerelease numeric suffixes numerically', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            beta: '1.0.0-beta.10',
+            latest: '1.0.0',
+          },
+          versions: {
+            '1.0.0-beta.2': {},
+            '1.0.0-beta.10': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.0.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: '1.0.0-beta.10',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('supports custom prerelease dist-tag channel names', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.0.0',
+            nightly: '1.0.0-nightly.2',
+          },
+          versions: {
+            '1.0.0-nightly.1': {},
+            '1.0.0-nightly.2': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion(
+        '1.0.0-nightly.1',
+        'nightly',
+      );
+
+      expect(result).toEqual({
+        latestVersion: '1.0.0-nightly.2',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('fallback dist-tags never return lower-major versions as compatible', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async (url: string) => {
+        if (url.includes('/-/package/')) {
+          return Response.json({ latest: '1.5.0' });
+        }
+
+        return new Response(null, { status: 503 });
+      }) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('2.0.0');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('fallback dist-tags never return stable latest for prerelease channel', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async (url: string) => {
+        if (url.includes('/-/package/')) {
+          return Response.json({ latest: '1.5.0' });
+        }
+
+        return new Response(null, { status: 503 });
+      }) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.4.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+  });
 });

+ 197 - 1
src/hooks/auto-update-checker/checker.ts

@@ -6,18 +6,28 @@ import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
   NPM_FETCH_TIMEOUT,
+  NPM_PACKAGE_URL,
   NPM_REGISTRY_URL,
   PACKAGE_NAME,
   USER_OPENCODE_CONFIG,
   USER_OPENCODE_CONFIG_JSONC,
 } from './constants';
 import type {
+  CompatibleVersionResult,
   NpmDistTags,
+  NpmPackageMetadata,
   OpencodeConfig,
   PackageJson,
   PluginEntryInfo,
 } from './types';
 
+interface ParsedVersion {
+  major: number;
+  minor: number;
+  patch: number;
+  prerelease: string | null;
+}
+
 function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
@@ -40,6 +50,82 @@ function isDistTag(version: string): boolean {
   return !/^\d/.test(version);
 }
 
+function parseVersion(version: string): ParsedVersion | null {
+  const normalized = version.trim().replace(/^[~^=<>\s]+/, '');
+  const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/);
+  if (!match) return null;
+
+  return {
+    major: Number(match[1]),
+    minor: Number(match[2]),
+    patch: Number(match[3]),
+    prerelease: match[4] ?? null,
+  };
+}
+
+function compareVersions(a: string, b: string): number {
+  const parsedA = parseVersion(a);
+  const parsedB = parseVersion(b);
+  if (!parsedA || !parsedB) return a.localeCompare(b);
+
+  const parts: Array<keyof Pick<ParsedVersion, 'major' | 'minor' | 'patch'>> = [
+    'major',
+    'minor',
+    'patch',
+  ];
+  for (const part of parts) {
+    if (parsedA[part] !== parsedB[part]) {
+      return parsedA[part] - parsedB[part];
+    }
+  }
+
+  if (parsedA.prerelease === parsedB.prerelease) return 0;
+  if (!parsedA.prerelease) return 1;
+  if (!parsedB.prerelease) return -1;
+  return comparePrerelease(parsedA.prerelease, parsedB.prerelease);
+}
+
+function comparePrerelease(a: string, b: string): number {
+  const segmentsA = a.split('.');
+  const segmentsB = b.split('.');
+  const length = Math.max(segmentsA.length, segmentsB.length);
+
+  for (let i = 0; i < length; i++) {
+    const segmentA = segmentsA[i];
+    const segmentB = segmentsB[i];
+    if (segmentA === segmentB) continue;
+    if (segmentA === undefined) return -1;
+    if (segmentB === undefined) return 1;
+
+    const numberA = Number(segmentA);
+    const numberB = Number(segmentB);
+    const numericA = Number.isInteger(numberA);
+    const numericB = Number.isInteger(numberB);
+
+    if (numericA && numericB) return numberA - numberB;
+    if (numericA) return -1;
+    if (numericB) return 1;
+
+    const comparison = segmentA.localeCompare(segmentB);
+    if (comparison !== 0) return comparison;
+  }
+
+  return 0;
+}
+
+function getPrereleaseChannel(version: ParsedVersion): string | null {
+  if (!version.prerelease) return null;
+
+  return version.prerelease.split('.')[0] ?? null;
+}
+
+function isVersionInChannel(version: string, channel: string): boolean {
+  const parsed = parseVersion(version);
+  if (!parsed) return false;
+  if (channel === 'latest') return parsed.prerelease === null;
+  return getPrereleaseChannel(parsed) === channel;
+}
+
 /**
  * Extracts the update channel (latest, alpha, beta, etc.) from a version string.
  * @param version The version or tag to analyze.
@@ -287,6 +373,11 @@ export function updatePinnedVersion(
 export async function getLatestVersion(
   channel: string = 'latest',
 ): Promise<string | null> {
+  const distTags = await fetchDistTags();
+  return distTags?.[channel] ?? distTags?.latest ?? null;
+}
+
+async function fetchDistTags(): Promise<NpmDistTags | null> {
   const controller = new AbortController();
   const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
 
@@ -299,10 +390,115 @@ export async function getLatestVersion(
     if (!response.ok) return null;
 
     const data = (await response.json()) as NpmDistTags;
-    return data[channel] ?? data.latest ?? null;
+    return data;
   } catch {
     return null;
   } finally {
     clearTimeout(timeoutId);
   }
 }
+
+/**
+ * Resolves the newest version that is safe for the current install to use.
+ * Auto-update never crosses major versions; newer majors are surfaced as a
+ * manual migration notification instead.
+ */
+export async function getLatestCompatibleVersion(
+  currentVersion: string,
+  channel: string = 'latest',
+): Promise<CompatibleVersionResult> {
+  const current = parseVersion(currentVersion);
+  if (!current) {
+    const latestVersion = await getLatestVersion(channel);
+    return {
+      latestVersion: null,
+      latestMajorVersion: latestVersion,
+      blockedByMajor: latestVersion !== null,
+      unsafeReason: latestVersion ? 'unparseable-current-version' : undefined,
+    };
+  }
+
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
+
+  try {
+    const response = await fetch(NPM_PACKAGE_URL, {
+      signal: controller.signal,
+      headers: { Accept: 'application/json' },
+    });
+
+    if (!response.ok) return await getCompatibleFromDistTags(current, channel);
+
+    const data = (await response.json()) as NpmPackageMetadata;
+    const distTags = data['dist-tags'] ?? { latest: '' };
+    const taggedVersion = distTags[channel] ?? distTags.latest ?? null;
+    const latestMajorVersion = getBlockingMajorVersion(current, [
+      taggedVersion,
+      distTags.latest,
+    ]);
+    const blockedByMajor = latestMajorVersion !== null;
+
+    const versions = Object.keys(data.versions ?? {})
+      .filter((version) => {
+        const parsed = parseVersion(version);
+        return (
+          parsed?.major === current.major &&
+          isVersionInChannel(version, channel)
+        );
+      })
+      .sort(compareVersions);
+    const latestVersion = versions.at(-1) ?? null;
+
+    return { latestVersion, latestMajorVersion, blockedByMajor };
+  } catch {
+    return await getCompatibleFromDistTags(current, channel);
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+async function getCompatibleFromDistTags(
+  current: ParsedVersion,
+  channel: string,
+): Promise<CompatibleVersionResult> {
+  const distTags = await fetchDistTags();
+  if (!distTags) {
+    return {
+      latestVersion: null,
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    };
+  }
+
+  const latestVersion = distTags[channel] ?? distTags.latest ?? null;
+  const latestMajorVersion = getBlockingMajorVersion(current, [
+    latestVersion,
+    distTags.latest,
+  ]);
+  const blockedByMajor = latestMajorVersion !== null;
+  const parsedLatest = latestVersion ? parseVersion(latestVersion) : null;
+  const compatibleLatestVersion =
+    parsedLatest?.major === current.major &&
+    latestVersion &&
+    isVersionInChannel(latestVersion, channel)
+      ? latestVersion
+      : null;
+
+  return {
+    latestVersion: compatibleLatestVersion,
+    latestMajorVersion,
+    blockedByMajor,
+  };
+}
+
+function getBlockingMajorVersion(
+  current: ParsedVersion,
+  candidates: Array<string | null | undefined>,
+): string | null {
+  for (const candidate of candidates) {
+    const parsed = candidate ? parseVersion(candidate) : null;
+    if (parsed && parsed.major > current.major) return candidate ?? null;
+  }
+
+  return null;
+}

+ 1 - 0
src/hooks/auto-update-checker/constants.ts

@@ -4,6 +4,7 @@ import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
+export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
 export const NPM_FETCH_TIMEOUT = 5000;
 
 function getCacheDir(): string {

+ 132 - 4
src/hooks/auto-update-checker/index.test.ts

@@ -6,6 +6,11 @@ const checkerMocks = {
   extractChannel: mock(() => 'latest'),
   findPluginEntry: mock(() => null),
   getCachedVersion: mock(() => null),
+  getLatestCompatibleVersion: mock(async () => ({
+    latestVersion: null,
+    latestMajorVersion: null,
+    blockedByMajor: false,
+  })),
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
@@ -82,6 +87,12 @@ describe('auto-update-checker/index', () => {
     checkerMocks.findPluginEntry.mockImplementation(() => null);
     checkerMocks.getCachedVersion.mockReset();
     checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestCompatibleVersion.mockReset();
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: null,
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
     checkerMocks.getLatestVersion.mockReset();
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
@@ -140,7 +151,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(0),
@@ -184,7 +199,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
@@ -215,7 +234,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -245,7 +268,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(1),
@@ -279,4 +306,105 @@ describe('auto-update-checker/index', () => {
       },
     });
   });
+
+  test('does not auto-update across major versions', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '1.1.2');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '1.1.2',
+      latestMajorVersion: '2.0.0',
+      blockedByMajor: true,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'oh-my-opencode-slim v2.0.0 is available.',
+        message:
+          'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+        variant: 'info',
+        duration: 12000,
+      },
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
+
+  test('shows only migration toast when compatible and blocked major updates coexist', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '1.0.0');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '1.5.0',
+      latestMajorVersion: '2.0.0',
+      blockedByMajor: true,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledTimes(1);
+    expect(showToast).toHaveBeenCalledWith({
+      body: expect.objectContaining({
+        title: 'oh-my-opencode-slim v2.0.0 is available.',
+      }),
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
+
+  test('does not show migration copy for unparseable current versions', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: 'workspace:*',
+      isPinned: true,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: null,
+      latestMajorVersion: '1.9.0',
+      blockedByMajor: true,
+      unsafeReason: 'unparseable-current-version',
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledTimes(1);
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim 1.9.0',
+        message:
+          'v1.9.0 available. Auto-update skipped because the current version could not be compared safely.',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
 });

+ 37 - 2
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,7 @@ import {
   extractChannel,
   findPluginEntry,
   getCachedVersion,
-  getLatestVersion,
+  getLatestCompatibleVersion,
   getLocalDevVersion,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
@@ -77,7 +77,32 @@ async function runBackgroundUpdateCheck(
   }
 
   const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
-  const latestVersion = await getLatestVersion(channel);
+  const latestInfo = await getLatestCompatibleVersion(currentVersion, channel);
+  if (latestInfo.unsafeReason === 'unparseable-current-version') {
+    log(
+      `[auto-update-checker] Current version is not semver; skipping auto-update: ${currentVersion}`,
+    );
+    if (latestInfo.latestMajorVersion) {
+      showToast(
+        ctx,
+        `OMO-Slim ${latestInfo.latestMajorVersion}`,
+        `v${latestInfo.latestMajorVersion} available. Auto-update skipped because the current version could not be compared safely.`,
+        'info',
+        8000,
+      );
+    }
+    return;
+  }
+
+  if (latestInfo.blockedByMajor && latestInfo.latestMajorVersion) {
+    showMajorUpgradeToast(ctx, latestInfo.latestMajorVersion);
+    log(
+      `[auto-update-checker] Major update available; skipping auto-update: ${latestInfo.latestMajorVersion}`,
+    );
+    return;
+  }
+
+  const latestVersion = latestInfo.latestVersion;
   if (!latestVersion) {
     log(
       '[auto-update-checker] Failed to fetch latest version for channel:',
@@ -160,6 +185,16 @@ async function runBackgroundUpdateCheck(
   }
 }
 
+function showMajorUpgradeToast(ctx: PluginInput, version: string): void {
+  showToast(
+    ctx,
+    `oh-my-opencode-slim v${version} is available.`,
+    'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+    'info',
+    12_000,
+  );
+}
+
 export function getAutoUpdateInstallDir(): string {
   return resolveInstallContext()?.installDir ?? CACHE_DIR;
 }

+ 12 - 0
src/hooks/auto-update-checker/types.ts

@@ -3,6 +3,18 @@ export interface NpmDistTags {
   [key: string]: string;
 }
 
+export interface NpmPackageMetadata {
+  'dist-tags'?: NpmDistTags;
+  versions?: Record<string, unknown>;
+}
+
+export interface CompatibleVersionResult {
+  latestVersion: string | null;
+  latestMajorVersion: string | null;
+  blockedByMajor: boolean;
+  unsafeReason?: 'unparseable-current-version';
+}
+
 export interface OpencodeConfig {
   plugin?: unknown[];
   [key: string]: unknown;

+ 16 - 5
src/index.ts

@@ -10,6 +10,7 @@ import {
 } from './config';
 import { parseList } from './config/agent-mcps';
 import { AGENT_ALIASES } from './config/constants';
+import { normalizeFallbackChainsForPreset } from './config/fallback-chains';
 import {
   getActiveRuntimePreset,
   getPreviousRuntimePreset,
@@ -200,11 +201,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
       }
     }
+    const activePresetForFallback =
+      getActiveRuntimePreset() ?? config.preset ?? null;
+
     if (config.fallback?.enabled !== false) {
-      const chains =
-        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
+      const chains = normalizeFallbackChainsForPreset(
+        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {},
+        activePresetForFallback,
+      );
       for (const [agentName, chainModels] of Object.entries(chains)) {
-        if (!chainModels?.length) continue;
         const existing = runtimeChains[agentName] ?? [];
         const seen = new Set(existing);
         for (const m of chainModels) {
@@ -222,6 +227,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       type: config.multiplexer?.type ?? 'none',
       layout: config.multiplexer?.layout ?? 'main-vertical',
       main_pane_size: config.multiplexer?.main_pane_size ?? 60,
+      zellij_pane_mode: config.multiplexer?.zellij_pane_mode ?? 'agent-tab',
     };
 
     // Get multiplexer instance for capability checks
@@ -450,10 +456,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Runtime failover on API errors (e.g. rate limits
       // mid-conversation) is handled separately by
       // ForegroundFallbackManager via the event hook.
+      const activePresetForFallback =
+        getActiveRuntimePreset() ?? config.preset ?? null;
       const fallbackChainsEnabled = config.fallback?.enabled !== false;
       const fallbackChains = fallbackChainsEnabled
-        ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
-          {})
+        ? normalizeFallbackChainsForPreset(
+            (config.fallback?.chains as Record<string, string[] | undefined>) ??
+              {},
+            activePresetForFallback,
+          )
         : {};
 
       // Build effective model arrays: seed from _modelArray, then append

+ 6 - 2
src/multiplexer/codemap.md

@@ -36,8 +36,12 @@
   - First child uses default pane in that tab; additional children create panes.
   - Falls back to first available pane ID heuristics and restores original tab
     context around cross-tab operations.
-  - Layout configuration is accepted but effectively no-op (tool semantics differ
-    from tmux).
+  - `current-tab` pane mode targets the tab containing the parent OpenCode pane
+    via `ZELLIJ_PANE_ID` + `list-panes --json --tab --all`, not whichever tab
+    is focused when a child session starts.
+  - Layout configuration maps `main-vertical` to right and `main-horizontal` to
+    down; `tiled`/`even-horizontal`/`even-vertical` use Zellij native placement
+    and `main_pane_size` remains a no-op.
 
 - `session-manager.ts` (`MultiplexerSessionManager`)
   - Initialized once from plugin context and config.

+ 4 - 0
src/multiplexer/factory.test.ts

@@ -23,6 +23,7 @@ describe('multiplexer factory', () => {
       type: 'tmux',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     process.env.TMUX_PANE = '%2';
@@ -34,6 +35,7 @@ describe('multiplexer factory', () => {
       type: 'tmux',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     expect(first).not.toBeNull();
@@ -51,6 +53,7 @@ describe('multiplexer factory', () => {
       type: 'auto',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     process.env.TMUX_PANE = '%2';
@@ -62,6 +65,7 @@ describe('multiplexer factory', () => {
       type: 'auto',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     expect(first).not.toBeNull();

+ 6 - 1
src/multiplexer/factory.ts

@@ -32,7 +32,11 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       actualType = 'tmux';
       break;
     case 'zellij':
-      multiplexer = new ZellijMultiplexer(config.layout, config.main_pane_size);
+      multiplexer = new ZellijMultiplexer(
+        config.layout,
+        config.main_pane_size,
+        config.zellij_pane_mode,
+      );
       actualType = 'zellij';
       break;
     case 'auto': {
@@ -45,6 +49,7 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
         multiplexer = new ZellijMultiplexer(
           config.layout,
           config.main_pane_size,
+          config.zellij_pane_mode,
         );
         actualType = 'zellij';
       } else {

+ 1 - 0
src/multiplexer/session-manager.test.ts

@@ -65,6 +65,7 @@ const defaultMultiplexerConfig = {
   type: 'tmux' as const,
   layout: 'main-vertical' as const,
   main_pane_size: 60,
+  zellij_pane_mode: 'agent-tab' as const,
 };
 
 function createDeferred<T>() {

+ 434 - 0
src/multiplexer/zellij/index.test.ts

@@ -0,0 +1,434 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(
+  exitCode = 0,
+  stdout = '',
+  stderr = '',
+): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(stdout),
+    stderr: () => Promise.resolve(stderr),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+function createPaneListJson(parentTabId = 0): string {
+  return JSON.stringify([
+    {
+      id: 0,
+      is_plugin: false,
+      tab_id: parentTabId,
+    },
+    {
+      id: 4,
+      is_plugin: false,
+      tab_id: 1,
+    },
+  ]);
+}
+
+async function importFreshZellij() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+async function spawnSecondAgentTabPane(
+  layout: 'main-vertical' | 'main-horizontal' | 'tiled',
+): Promise<string[] | undefined> {
+  const { ZellijMultiplexer } = await importFreshZellij();
+  const zellij = new ZellijMultiplexer(layout, 60, 'agent-tab');
+
+  crossSpawnMock.mockImplementation((command: string[]) => {
+    if (command[0] === 'which') {
+      return createSpawnResult(0, '/usr/bin/zellij\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')) {
+      return createSpawnResult(0, 'PANE ID\nterminal_7\n');
+    }
+    if (command.includes('new-pane')) {
+      return createSpawnResult(0, 'terminal_8\n');
+    }
+    return createSpawnResult();
+  });
+
+  await zellij.spawnPane(
+    'session-1',
+    'First agent worker',
+    'http://localhost:4096',
+    '/repo',
+  );
+
+  await zellij.spawnPane(
+    'session-2',
+    'Second agent worker',
+    'http://localhost:4096',
+    '/repo',
+  );
+
+  return commands().findLast((command) => command.includes('new-pane'));
+}
+
+describe('ZellijMultiplexer', () => {
+  const originalZellij = process.env.ZELLIJ;
+  const originalZellijPaneId = process.env.ZELLIJ_PANE_ID;
+
+  beforeEach(() => {
+    process.env.ZELLIJ = '1';
+    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();
+    });
+  });
+
+  afterEach(() => {
+    process.env.ZELLIJ = originalZellij;
+    process.env.ZELLIJ_PANE_ID = originalZellijPaneId;
+  });
+
+  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');
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+
+    const allCommands = commands();
+    const newPaneCommand = allCommands.find((command) =>
+      command.includes('new-pane'),
+    );
+
+    expect(newPaneCommand).toEqual([
+      '/usr/bin/zellij',
+      'action',
+      'new-pane',
+      '--tab-id',
+      '0',
+      '--direction',
+      'right',
+      '--name',
+      'Current tab worker',
+      '--close-on-exit',
+      '--',
+      'sh',
+      '-lc',
+      "opencode attach 'http://localhost:4096' --session 'session-1' --dir '/repo'",
+    ]);
+    expect(allCommands.some((command) => command.includes('new-tab'))).toBe(
+      false,
+    );
+    expect(
+      allCommands.some((command) => command.includes('go-to-tab-by-id')),
+    ).toBe(false);
+  });
+
+  test('current-tab mode reports failure when zellij does not return a terminal pane id', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    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, 'plugin_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  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') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson(0));
+      }
+      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();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+
+    const tabIdArgIndex = newPaneCommand?.indexOf('--tab-id') ?? -1;
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('0');
+  });
+
+  test('current-tab mode accepts terminal-prefixed parent pane ids', async () => {
+    process.env.ZELLIJ_PANE_ID = 'terminal_0';
+
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    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('0');
+  });
+
+  test('current-tab mode falls back to the focused tab if 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') {
+        return createSpawnResult(0, '/usr/bin/zellij\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(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    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');
+  });
+
+  test('current-tab mode caches the fallback focused tab after parent tab lookup fails', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+    let currentTabId = 1;
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        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'),
+    );
+
+    expect(
+      newPaneCommands.map((command) => {
+        const tabIdArgIndex = command.indexOf('--tab-id');
+        return command[tabIdArgIndex + 1];
+      }),
+    ).toEqual(['1', '1']);
+  });
+
+  test('main-horizontal layout opens current-tab panes down', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-horizontal', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    const directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('even-horizontal layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('even-horizontal', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('even-vertical layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('even-vertical', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('tiled layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('tiled', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('main-vertical layout opens agent-tab panes right', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('main-vertical');
+    const directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('right');
+  });
+
+  test('main-horizontal layout opens agent-tab panes down', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('main-horizontal');
+    const directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('tiled layout uses zellij native agent-tab pane placement', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('tiled');
+
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+});

+ 157 - 10
src/multiplexer/zellij/index.ts

@@ -1,13 +1,18 @@
 /**
  * Zellij multiplexer implementation
  *
- * Creates a dedicated "opencode-agents" tab for all sub-agent panes.
+ * Creates panes for sub-agent sessions in Zellij.
+ *
+ * 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
+ *
+ * The optional "current-tab" mode creates panes in the tab containing the
+ * parent OpenCode pane instead.
  */
 
-import type { MultiplexerLayout } from '../../config/schema';
+import type { MultiplexerLayout, ZellijPaneMode } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import type { Multiplexer, PaneResult } from '../types';
 
@@ -18,6 +23,14 @@ interface ZellijTabInfo {
   tab_id: number;
 }
 
+interface ZellijPaneInfo {
+  id: number;
+  is_plugin: boolean;
+  tab_id?: number;
+}
+
+type ZellijPaneDirection = 'right' | 'down';
+
 export class ZellijMultiplexer implements Multiplexer {
   readonly type = 'zellij' as const;
 
@@ -26,13 +39,19 @@ export class ZellijMultiplexer implements Multiplexer {
   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.
-    void layout;
+  private parentTabId: string | null = null;
+  private readonly parentPaneId = process.env.ZELLIJ_PANE_ID;
+  private readonly paneDirection: ZellijPaneDirection | null;
+
+  constructor(
+    layout: MultiplexerLayout = 'main-vertical',
+    mainPaneSize = 60,
+    private readonly paneMode: ZellijPaneMode = 'agent-tab',
+  ) {
+    // Note: Zellij does not support exact main pane sizing like tmux.
+    // Layout config is mapped to pane creation directions where possible.
     void mainPaneSize;
+    this.paneDirection = getPaneDirection(layout);
   }
 
   async isAvailable(): Promise<boolean> {
@@ -58,6 +77,16 @@ export class ZellijMultiplexer implements Multiplexer {
     if (!zellij) return { success: false };
 
     try {
+      if (this.paneMode === 'current-tab') {
+        return await this.createPaneInCurrentTab(
+          zellij,
+          sessionId,
+          serverUrl,
+          directory,
+          description,
+        );
+      }
+
       // Ensure agent tab exists on first call
       if (!this.agentTabId) {
         const result = await this.ensureAgentTab(zellij);
@@ -96,6 +125,50 @@ export class ZellijMultiplexer implements Multiplexer {
     }
   }
 
+  private async createPaneInCurrentTab(
+    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 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',
+    });
+
+    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(
     zellij: string,
     sessionId: string,
@@ -118,6 +191,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const args = [
         'action',
         'new-pane',
+        ...this.directionArgs(),
         '--name',
         paneName,
         '--close-on-exit',
@@ -160,6 +234,7 @@ export class ZellijMultiplexer implements Multiplexer {
     const args = [
       'action',
       'new-pane',
+      ...this.directionArgs(),
       '--name',
       paneName,
       '--close-on-exit',
@@ -448,8 +523,61 @@ export class ZellijMultiplexer implements Multiplexer {
     _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.
+    // No-op for zellij after panes are spawned. Zellij does not support tmux-like
+    // exact main pane sizing/rebalancing; layout is applied to future pane
+    // creation by mapping configured layouts to pane directions.
+  }
+
+  private directionArgs(): string[] {
+    return this.paneDirection ? ['--direction', this.paneDirection] : [];
+  }
+
+  private tabIdArgs(tabId: string | null): string[] {
+    return tabId ? ['--tab-id', tabId] : [];
+  }
+
+  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;
+      }
+    }
+
+    this.parentTabId = await this.getCurrentTabId(zellij);
+    return this.parentTabId;
+  }
+
+  private async findTabIdForPane(
+    zellij: string,
+    paneId: string,
+  ): Promise<string | null> {
+    try {
+      const proc = crossSpawn(
+        [zellij, 'action', 'list-panes', '--json', '--tab', '--all'],
+        {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        },
+      );
+
+      if ((await proc.exited) !== 0) return null;
+
+      const stdout = await proc.stdout();
+      const panes: ZellijPaneInfo[] = JSON.parse(stdout);
+      const normalizedPaneId = normalizePaneId(paneId);
+      const pane = panes.find(
+        (candidate) =>
+          !candidate.is_plugin && String(candidate.id) === normalizedPaneId,
+      );
+
+      return pane?.tab_id === undefined ? null : String(pane.tab_id);
+    } catch {
+      return null;
+    }
   }
 
   private async getBinary(): Promise<string | null> {
@@ -473,6 +601,25 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 }
 
+function normalizePaneId(paneId: string): string {
+  return paneId.replace(/^terminal_/, '');
+}
+
+function getPaneDirection(
+  layout: MultiplexerLayout,
+): ZellijPaneDirection | null {
+  switch (layout) {
+    case 'main-vertical':
+      return 'right';
+    case 'main-horizontal':
+      return 'down';
+    case 'even-horizontal':
+    case 'even-vertical':
+    case 'tiled':
+      return null;
+  }
+}
+
 function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,