Browse Source

feat: add Herdr multiplexer backend

Add Herdr (https://herdr.dev) as a third terminal multiplexer alongside
tmux and zellij. Herdr is an agent-aware terminal multiplexer whose CLI
outputs newline-delimited JSON.

Implementation:
- HerdrMultiplexer: splits parent pane via , parses
  JSON response to extract pane_id, renames pane, runs
  via . Close = ctrl+c then .
- Auto-detect via HERDR_ENV / HERDR_PANE_ID env vars (injected by Herdr
  into every managed pane).
- Layout mapping: main-vertical/even-horizontal/tiled → right split,
  main-horizontal/even-vertical → down split. No layout rebalancing
  (like zellij).
- Config: multiplexer.type now accepts 'herdr'; auto mode detects it.
- 17 new tests (14 herdr backend + 3 factory auto-detect).
- Docs: multiplexer-integration guide, configuration reference, README
  (en/zh/ja/ko), codemaps all updated.
Shank 1 month ago
parent
commit
a9ebba830e

+ 2 - 2
README.ja-JP.md

@@ -114,7 +114,7 @@ bunx oh-my-opencode-slim@latest install
 > バックグラウンドオーケストレーションの仕組みを理解しておくことを**推奨**します。**[Orchestrator のプロンプト](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** には、スケジューラーのルール、専門エージェントへのルーティングロジック、作業をバックグラウンドエージェントへ割り当てるしきい値が記述されています。`@agentName <task>` のようにサブエージェントを呼び出すことで、いつでも手動で委譲できます。
 
 > [!TIP]
-> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux または Zellij ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
+> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux、Zellij または Herdr ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
 
 デフォルトで生成される設定には `openai` と `opencode-go` の両方のプリセットが含まれます。
 
@@ -610,7 +610,7 @@ Worktrees は、Git worktree を `.slim/worktrees/<slug>/` 配下の安全で隔
 | **[Council](docs/council.md)** | 複数のモデルを並列実行し、`@council` で 1 つの回答に統合します |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | カスタムプロンプト、モデル、MCP アクセス、Orchestrator の委譲ルールを備えた独自の専門エージェントを定義します |
 | **[ACP Agents](docs/acp-agents.md)** | Claude Code ACP や Gemini ACP などの外部 ACP 互換エージェントを委譲可能なサブエージェントとして接続します |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux や Zellij のペインでライブ表示します |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux、Zellij や Herdr のペインでライブ表示します |
 | **[Codemap](docs/codemap.md)** | 階層的なコードマップを生成し、大規模コードベースを迅速に理解します |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |

+ 2 - 2
README.ko-KR.md

@@ -112,7 +112,7 @@ bunx oh-my-opencode-slim@latest install
 > 자동 위임이 어떻게 동작하는지 이해하는 것을 **권장**합니다. **[Orchestrator 프롬프트](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 에는 위임 규칙, 전문 에이전트 라우팅 로직, 메인 에이전트가 언제 서브에이전트로 작업을 넘겨야 하는지에 대한 임계값이 포함되어 있습니다. 수동으로 위임하려면 `@agentName <task>`로 서브에이전트를 호출하면 됩니다.
 
 > [!TIP]
-> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux 또는 Zellij 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
+> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux, Zellij, 또는 Herdr 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
 
 기본 생성 설정에는 `openai`와 `opencode-go` 프리셋이 모두 포함되어 있습니다.
 
@@ -608,7 +608,7 @@ Worktrees는 Git worktree를 `.slim/worktrees/<slug>/` 아래의 안전하고 
 | **[Council](docs/council.md)** | `@council`로 여러 모델을 병렬 실행하고 하나의 답변으로 종합 |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | 커스텀 프롬프트, 모델, MCP 접근, Orchestrator 위임 규칙으로 커스텀 전문 에이전트 정의 |
 | **[ACP Agents](docs/acp-agents.md)** | Claude Code ACP 또는 Gemini ACP 같은 외부 ACP 호환 에이전트를 위임 가능한 서브에이전트로 연결 |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux 또는 Zellij 페인에서 에이전트 작업을 실시간으로 확인 |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux, Zellij, 또는 Herdr 페인에서 에이전트 작업을 실시간으로 확인 |
 | **[Codemap](docs/codemap.md)** | 계층형 코드맵을 생성하여 대규모 코드베이스를 빠르게 파악 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |

+ 2 - 2
README.md

@@ -109,7 +109,7 @@ Then:
 > It's **recommended** to understand how background orchestration works. The **[Orchestrator prompt](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** contains the scheduler rules, specialist routing logic, and thresholds for when work should be assigned to background agents. You can always delegate manually by calling a subagent via: `@agentName <task>`
 
 > [!TIP]
-> Because background agents are now the default workflow, it is **highly recommended** to enable and configure **[Multiplexer Integration](docs/multiplexer-integration.md)**. It automatically opens each agent in a dedicated Tmux or Zellij pane, so you can watch specialists work live while the Orchestrator continues coordinating the session.
+> Because background agents are now the default workflow, it is **highly recommended** to enable and configure **[Multiplexer Integration](docs/multiplexer-integration.md)**. It automatically opens each agent in a dedicated Tmux, Zellij, or Herdr pane, so you can watch specialists work live while the Orchestrator continues coordinating the session.
 
 The default generated configuration includes both `openai` and `opencode-go` presets.
 
@@ -648,7 +648,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | Define your own specialists with custom prompts, models, MCP access, and Orchestrator delegation rules |
 | **[ACP Agents](docs/acp-agents.md)** | Connect external ACP-compatible agents such as Claude Code ACP or Gemini ACP as delegatable subagents |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux, Zellij, or Herdr panes |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |
 | **[Clonedeps](docs/clonedeps.md)** | Clone selected dependency source into an ignored local workspace for inspection |
 | **[Worktrees](docs/worktrees.md)** | Use `.slim/worktrees/` lanes for isolated parallel or risky coding work |

+ 2 - 2
README.zh-CN.md

@@ -109,7 +109,7 @@ bunx oh-my-opencode-slim@latest install
 > **建议**了解后台编排的工作原理。**[编排者提示词 (Orchestrator prompt)](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 包含调度规则、专家路由逻辑,以及何时应把工作分配给后台智能体的阈值。您始终可以通过以下方式手动委派任务:`@智能体名称 <任务内容>`
 
 > [!TIP]
-> 由于后台智能体现在是默认工作流,**强烈建议**启用并配置 **[Multiplexer Integration](docs/multiplexer-integration.md)**。它会自动在专用的 Tmux 或 Zellij 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
+> 由于后台智能体现在是默认工作流,**强烈建议**启用并配置 **[Multiplexer Integration](docs/multiplexer-integration.md)**。它会自动在专用的 Tmux、Zellij 或 Herdr 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
 
 默认生成的配置包含 `openai` 和 `opencode-go` 两个预设:
 
@@ -605,7 +605,7 @@ Worktrees 将 Git worktree 作为安全、隔离的编码通道管理,默认
 | **[Council](docs/council.md)** | 使用 `@council` 并行运行多个模型并合成单一答案 |
 | **[自定义智能体](docs/configuration.md#custom-agents)** | 使用自定义提示词、模型、MCP 访问和 Orchestrator 委派规则定义自己的专家 |
 | **[ACP Agents](docs/acp-agents.md)** | 将 Claude Code ACP 或 Gemini ACP 等外部 ACP 兼容智能体连接为可委派子智能体 |
-| **[多路复用器集成](docs/multiplexer-integration.md)** | 在 Tmux 或 Zellij 窗格中实时观看智能体工作 |
+| **[多路复用器集成](docs/multiplexer-integration.md)** | 在 Tmux、Zellij 或 Herdr 窗格中实时观看智能体工作 |
 | **[Codemap](docs/codemap.md)** | 生成层级代码地图,更快理解大型代码库 |
 | **[Clonedeps](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[Worktrees](docs/worktrees.md)** | 使用 `.slim/worktrees/` 通道进行隔离的并行或高风险编码工作 |

+ 1 - 0
codemap.md

@@ -47,6 +47,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
 | `src/multiplexer/tmux/` | tmux backend implementation for pane lifecycle and layout management. | [View Map](src/multiplexer/tmux/codemap.md) |
 | `src/multiplexer/zellij/` | zellij backend implementation for tab/pane lifecycle. | [View Map](src/multiplexer/zellij/codemap.md) |
+| `src/multiplexer/herdr/` | herdr backend implementation for pane lifecycle. | [View Map](src/multiplexer/herdr/codemap.md) |
 | `src/skills/` | Bundled install-time OpenCode skills shipped as static payloads. | [View Map](src/skills/codemap.md) |
 | `src/skills/codemap/` | Repository-mapping skill package and codemap state-management script. | [View Map](src/skills/codemap/codemap.md) |
 | `src/skills/clonedeps/` | Workflow-only dependency source mirroring skill that routes discovery/ref resolution through librarian and direct orchestrator git operations. | [View Map](src/skills/clonedeps/codemap.md) |

+ 3 - 3
docs/configuration.md

@@ -126,9 +126,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) |
 | `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`. 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.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, or `none` |
+| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij and Herdr map `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 and Herdr |
 | `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 |
 | `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |

+ 36 - 8
docs/multiplexer-integration.md

@@ -1,6 +1,6 @@
 # Multiplexer Integration Guide
 
-Use tmux or Zellij to watch subagents work in live panes while OpenCode keeps running in your main session.
+Use tmux, Zellij, or Herdr to watch subagents work in live panes while OpenCode keeps running in your main session.
 
 ## Table of Contents
 
@@ -83,7 +83,17 @@ Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 }
 ```
 
-### 2. Start OpenCode inside tmux or Zellij
+**Herdr only:**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "herdr"
+  }
+}
+```
+
+### 2. Start OpenCode inside tmux, Zellij, or Herdr
 
 **Tmux:**
 
@@ -99,6 +109,13 @@ zellij
 opencode --port 4096
 ```
 
+**Herdr:**
+
+```bash
+herdr
+opencode --port 4096
+```
+
 ### 3. Trigger delegated work
 
 Ask OpenCode to do something that launches subagents. New panes should appear automatically.
@@ -127,9 +144,9 @@ 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; mapped to Zellij pane directions where possible |
-| `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`) |
+| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, `"herdr"`, or `"none"` |
+| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij/Herdr pane directions where possible |
+| `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`); ignored by Zellij and Herdr |
 | `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
@@ -138,6 +155,7 @@ Please analyze this codebase and create a documentation structure.
 |-------------|--------|-------|
 | **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 |
+| **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) |
 
 **Example: open Zellij subagents in the parent OpenCode tab**
 
@@ -175,9 +193,9 @@ This is converted automatically to `multiplexer.type: "tmux"`.
 
 ## Layouts
 
-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.
+Tmux supports full layout control and main pane sizing. Zellij and Herdr map
+only the `main-*` layout settings to pane creation directions; exact
+`main_pane_size` rebalancing is tmux-only.
 
 | Layout | Description |
 |--------|-------------|
@@ -197,6 +215,16 @@ For Zellij:
 | `even-vertical` | Uses Zellij's native pane placement |
 | `tiled` | Uses Zellij's native pane placement |
 
+For Herdr:
+
+| Layout | Herdr behavior |
+|--------|-----------------|
+| `main-vertical` | Opens new subagent panes to the right |
+| `main-horizontal` | Opens new subagent panes down |
+| `even-horizontal` | Opens new subagent panes to the right |
+| `even-vertical` | Opens new subagent panes down |
+| `tiled` | Opens new subagent panes to the right |
+
 **Example: wide-screen layout**
 
 ```jsonc

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

@@ -927,6 +927,7 @@
             "auto",
             "tmux",
             "zellij",
+            "herdr",
             "none"
           ]
         },

+ 7 - 1
src/config/schema.ts

@@ -127,7 +127,13 @@ export const AgentOverrideConfigSchema = z
   .strict();
 
 // Multiplexer type options
-export const MultiplexerTypeSchema = z.enum(['auto', 'tmux', 'zellij', 'none']);
+export const MultiplexerTypeSchema = z.enum([
+  'auto',
+  'tmux',
+  'zellij',
+  'herdr',
+  'none',
+]);
 export type MultiplexerType = z.infer<typeof MultiplexerTypeSchema>;
 
 // Layout options (shared across multiplexers)

+ 11 - 3
src/multiplexer/codemap.md

@@ -4,7 +4,7 @@
 
 - Provide multiplexer-backed visualization for spawned subagent sessions.
 - Select and instantiate terminal backend based on config/env:
-  `auto`, `tmux`, `zellij`, or `none`.
+  `auto`, `tmux`, `zellij`, `herdr`, or `none`.
 - Manage lifecycle of child session panes with lifecycle hooks from OpenCode
   events plus health/polling fallback.
 - Keep pane cleanup safe and graceful (best-effort interrupt + kill).
@@ -43,6 +43,14 @@
     down; `tiled`/`even-horizontal`/`even-vertical` use Zellij native placement
     and `main_pane_size` remains a no-op.
 
+- `herdr/index.ts` (`HerdrMultiplexer`)
+  - Detects binary via `which`. `spawnPane` splits the parent pane (using
+    `HERDR_PANE_ID` or `--current`), renames it, and runs `opencode attach`
+    via `herdr pane run`. Parses JSON CLI output to extract pane IDs.
+  - `closePane` sends `ctrl+c` then `herdr pane close`.
+  - `applyLayout` is a no-op (like Zellij).
+  - Auto-detects via `HERDR_ENV`/`HERDR_PANE_ID` env vars.
+
 - `session-manager.ts` (`MultiplexerSessionManager`)
   - Initialized once from plugin context and config.
   - Subscribes to lifecycle events:
@@ -84,8 +92,8 @@
 - Integrates with OpenCode session events and server URL from plugin input.
 - Uses helper endpoints defined by `src/config` multiplexer settings:
   `type`, `layout`, `main_pane_size`.
-- Implementations in `src/multiplexer/tmux` and `src/multiplexer/zellij` are used
-  through the shared abstraction.
+- Implementations in `src/multiplexer/tmux`, `src/multiplexer/zellij`, and
+  `src/multiplexer/herdr` are used through the shared abstraction.
 - Validation coverage:
   - `src/multiplexer/factory.test.ts`
   - `src/multiplexer/session-manager.test.ts`

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

@@ -7,10 +7,14 @@ async function importFreshFactory(suffix: string) {
 describe('multiplexer factory', () => {
   const originalTmux = process.env.TMUX;
   const originalTmuxPane = process.env.TMUX_PANE;
+  const originalHerdrEnv = process.env.HERDR_ENV;
+  const originalHerdrPaneId = process.env.HERDR_PANE_ID;
 
   afterEach(() => {
     process.env.TMUX = originalTmux;
     process.env.TMUX_PANE = originalTmuxPane;
+    process.env.HERDR_ENV = originalHerdrEnv;
+    process.env.HERDR_PANE_ID = originalHerdrPaneId;
   });
 
   test('returns a fresh tmux instance per call', async () => {
@@ -72,4 +76,59 @@ describe('multiplexer factory', () => {
     expect(second).not.toBeNull();
     expect(Object.is(first, second)).toBe(false);
   });
+
+  test('returns a herdr instance when type is herdr', async () => {
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('herdr-explicit');
+
+    const multiplexer = getMultiplexer({
+      type: 'herdr',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
+
+  test('auto-detects herdr when HERDR_ENV is set', async () => {
+    delete process.env.TMUX;
+    delete process.env.TMUX_PANE;
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('auto-herdr');
+
+    const multiplexer = getMultiplexer({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
+
+  test('auto-detects herdr when only HERDR_PANE_ID is set', async () => {
+    delete process.env.TMUX;
+    delete process.env.TMUX_PANE;
+    delete process.env.HERDR_ENV;
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('auto-herdr-pane');
+
+    const multiplexer = getMultiplexer({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
 });

+ 19 - 5
src/multiplexer/factory.ts

@@ -4,6 +4,7 @@
 
 import type { MultiplexerConfig, MultiplexerType } from '../config/schema';
 import { log } from '../utils/logger';
+import { HerdrMultiplexer } from './herdr';
 import { TmuxMultiplexer } from './tmux';
 import type { Multiplexer } from './types';
 import { ZellijMultiplexer } from './zellij';
@@ -11,9 +12,9 @@ import { ZellijMultiplexer } from './zellij';
 /**
  * Create a multiplexer instance based on config.
  *
- * Do not cache instances: tmux/zellij integrations may depend on
- * per-process environment like TMUX_PANE/ZELLIJ, which should be captured
- * fresh for each plugin context.
+ * Do not cache instances: tmux/zellij/herdr integrations may depend on
+ * per-process environment like TMUX_PANE/ZELLIJ/HERDR_PANE_ID, which should
+ * be captured fresh for each plugin context.
  */
 export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
   const { type } = config;
@@ -39,6 +40,10 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       );
       actualType = 'zellij';
       break;
+    case 'herdr':
+      multiplexer = new HerdrMultiplexer(config.layout, config.main_pane_size);
+      actualType = 'herdr';
+      break;
     case 'auto': {
       // Auto-detect based on environment variables only
       // Note: Does NOT fall back to binary availability checks
@@ -52,6 +57,12 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
           config.zellij_pane_mode,
         );
         actualType = 'zellij';
+      } else if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
+        multiplexer = new HerdrMultiplexer(
+          config.layout,
+          config.main_pane_size,
+        );
+        actualType = 'herdr';
       } else {
         // Not inside any session, disable multiplexer
         log('[multiplexer] auto: not inside any session, disabling');
@@ -78,15 +89,18 @@ export function clearMultiplexerCache(): void {
 
 /**
  * Get the effective multiplexer type for auto mode
- * Returns the actual type that would be used (tmux/zellij/none)
+ * Returns the actual type that would be used (tmux/zellij/herdr/none)
  */
-export function getAutoMultiplexerType(): 'tmux' | 'zellij' | 'none' {
+export function getAutoMultiplexerType(): 'tmux' | 'zellij' | 'herdr' | 'none' {
   if (process.env.TMUX) {
     return 'tmux';
   }
   if (process.env.ZELLIJ) {
     return 'zellij';
   }
+  if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
+    return 'herdr';
+  }
   return 'none';
 }
 

+ 41 - 0
src/multiplexer/herdr/codemap.md

@@ -0,0 +1,41 @@
+# src/multiplexer/herdr/
+
+## Responsibility
+
+- Implement Herdr-backed pane orchestration for delegated sessions as an alternative to tmux and zellij.
+- Manage pane lifecycle (split, rename, run, close) within the current Herdr workspace.
+- Keep process cleanup safe and graceful (interrupt + close).
+
+## Design
+
+- `HerdrMultiplexer` in `index.ts` implements `Multiplexer`.
+- `findBinary` is a `which/where herdr` probe with cached path.
+- `isInsideSession` checks `process.env.HERDR_ENV` or `process.env.HERDR_PANE_ID`; `isAvailable` uses cached `binaryPath`.
+- `spawnPane` resolves the parent pane via `HERDR_PANE_ID` or `--current`, splits it in the configured direction, renames the new pane to the agent description, and runs `opencode attach` via `herdr pane run`. Pane IDs are extracted by parsing JSON CLI output from `herdr pane split`.
+- `closePane` sends `ctrl+c` via `herdr pane send_keys`, waits briefly, then runs `herdr pane close`.
+- `applyLayout` is intentionally a no-op (Herdr does not expose equivalent layout rebalancing APIs).
+- Layout direction mapping is done by `getPaneDirection`:
+  - `main-vertical`, `even-horizontal`, `tiled` → `right`
+  - `main-horizontal`, `even-vertical` → `down`
+
+## Flow
+
+- `spawnPane(sessionId, description, serverUrl, directory)`:
+  - resolve herdr binary via `getBinary()`
+  - determine parent pane from `HERDR_PANE_ID` env var or `--current`
+  - split parent pane via `herdr pane split <parent> --direction <dir>`
+  - parse JSON output to extract new pane ID (`new_pane_id`)
+  - rename the pane via `herdr pane send_text <pane> \x1b]0;<desc>\x07`
+  - run `opencode attach <url> --session <sessionId> --dir <directory>` via `herdr pane run <pane>`
+  - return `{ success, paneId }`
+- `closePane(paneId)`:
+  - `herdr pane send_keys <pane> ctrl+c`
+  - wait 250ms
+  - `herdr pane close <pane>`; treats exit codes `0` and `1` as successful closure.
+- `applyLayout` is a no-op retained for interface compatibility.
+
+## Integration
+
+- Selected when `multiplexerConfig.type === 'herdr'` or auto mode resolves to herdr (`process.env.HERDR_ENV` or `process.env.HERDR_PANE_ID` present).
+- Consumed by `MultiplexerSessionManager` as the pane backend in Herdr environments.
+- UI attach command semantics are identical to tmux in argument shape: `opencode attach <url> --session <sessionId> --dir <directory>`, so delegated sessions remain config-agnostic across backends.

+ 349 - 0
src/multiplexer/herdr/index.test.ts

@@ -0,0 +1,349 @@
+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/logger', () => ({
+  log: mock(() => {}),
+}));
+
+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 createSplitResponse(paneId: string): string {
+  return JSON.stringify({
+    id: 'cli:pane:split',
+    result: {
+      type: 'pane_info',
+      pane: {
+        pane_id: paneId,
+        tab_id: 'w1:t1',
+        workspace_id: 'w1',
+      },
+    },
+  });
+}
+
+async function importFreshHerdr() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+describe('HerdrMultiplexer', () => {
+  const originalHerdrEnv = process.env.HERDR_ENV;
+  const originalHerdrPaneId = process.env.HERDR_PANE_ID;
+
+  beforeEach(() => {
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p2')}\n`);
+      }
+      return createSpawnResult();
+    });
+  });
+
+  afterEach(() => {
+    process.env.HERDR_ENV = originalHerdrEnv;
+    process.env.HERDR_PANE_ID = originalHerdrPaneId;
+  });
+
+  test('spawns an opencode attach process in a herdr split pane', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'w1:p2' });
+
+    const allCommands = commands();
+
+    // 1. which herdr
+    expect(allCommands[0]).toEqual(['which', 'herdr']);
+
+    // 2. pane split
+    expect(allCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+
+    // 3. pane rename
+    expect(allCommands[2]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'rename',
+      'w1:p2',
+      'Herdr worker',
+    ]);
+
+    // 4. pane run
+    expect(allCommands[3]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'run',
+      'w1:p2',
+      "opencode attach 'http://localhost:4096' --session 'session-1' --dir '/repo'",
+    ]);
+  });
+
+  test('uses --current when HERDR_PANE_ID is not set', async () => {
+    delete process.env.HERDR_PANE_ID;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+
+    expect(splitCommand).toContain('--current');
+    expect(splitCommand).not.toContain('w1:p1');
+  });
+
+  test('closes herdr panes gracefully', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const success = await herdr.closePane('w1:p2');
+
+    expect(success).toBe(true);
+    expect(commands()).toEqual([
+      ['which', 'herdr'],
+      ['/usr/bin/herdr', 'pane', 'send-keys', 'w1:p2', 'ctrl+c'],
+      ['/usr/bin/herdr', 'pane', 'close', 'w1:p2'],
+    ]);
+  });
+
+  test('returns true when closing unknown pane id', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const success = await herdr.closePane('unknown');
+    expect(success).toBe(true);
+  });
+
+  test('reports failure when split returns non-zero exit code', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(1, '', 'split failed');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('reports failure when split output has no pane_id', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, 'not valid json\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('reports failure when pane run returns non-zero exit code', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p3')}\n`);
+      }
+      if (command.includes('run')) {
+        return createSpawnResult(1, '', 'run failed');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('main-horizontal layout opens panes down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-horizontal', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('even-vertical layout opens panes down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('even-vertical', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('tiled layout opens panes right', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('tiled', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('right');
+  });
+
+  test('isInsideSession returns true when HERDR_ENV is set', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(true);
+  });
+
+  test('isInsideSession returns true when HERDR_PANE_ID is set', async () => {
+    delete process.env.HERDR_ENV;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(true);
+  });
+
+  test('isInsideSession returns false when no herdr env vars are set', async () => {
+    delete process.env.HERDR_ENV;
+    delete process.env.HERDR_PANE_ID;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(false);
+  });
+
+  test('applyLayout is a no-op', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.applyLayout('tiled', 50);
+
+    // Only the binary check command should have been issued
+    expect(commands()).toHaveLength(0);
+  });
+});

+ 299 - 0
src/multiplexer/herdr/index.ts

@@ -0,0 +1,299 @@
+/**
+ * Herdr multiplexer implementation
+ *
+ * Splits panes for sub-agent sessions in Herdr.
+ *
+ * Herdr is an agent-aware terminal multiplexer (workspaces → tabs → panes).
+ * Pane IDs use the format `w<workspace>:p<pane>`. The CLI outputs
+ * newline-delimited JSON; `pane split` returns a `pane_info` result whose
+ * `pane.pane_id` field is the new pane's ID.
+ *
+ * Environment detection: Herdr injects `HERDR_ENV=1` and `HERDR_PANE_ID`
+ * into every pane it manages.
+ */
+
+import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
+import { log } from '../../utils/logger';
+import type { Multiplexer, PaneResult } from '../types';
+
+type HerdrPaneDirection = 'right' | 'down';
+
+interface HerdrCliResponse {
+  result?: {
+    type?: string;
+    pane?: { pane_id?: string };
+  };
+  error?: { code?: string; message?: string };
+}
+
+export class HerdrMultiplexer implements Multiplexer {
+  readonly type = 'herdr' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private readonly parentPaneId = process.env.HERDR_PANE_ID;
+  private readonly paneDirection: HerdrPaneDirection;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    // Herdr does not support exact main pane sizing like tmux.
+    // Layout config is mapped to pane split direction.
+    void mainPaneSize;
+    this.paneDirection = getPaneDirection(layout);
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.hasChecked) {
+      return this.binaryPath !== null;
+    }
+
+    this.binaryPath = await this.findBinary();
+    this.hasChecked = true;
+    return this.binaryPath !== null;
+  }
+
+  isInsideSession(): boolean {
+    return !!(process.env.HERDR_ENV || process.env.HERDR_PANE_ID);
+  }
+
+  async spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+    directory: string,
+  ): Promise<PaneResult> {
+    const herdr = await this.getBinary();
+    if (!herdr) {
+      log('[herdr] spawnPane: herdr binary not found');
+      return { success: false };
+    }
+
+    try {
+      // 1. Split the parent pane to create a new one
+      const splitArgs = [
+        herdr,
+        'pane',
+        'split',
+        ...this.targetPaneArg(),
+        '--direction',
+        this.paneDirection,
+        '--cwd',
+        directory,
+        '--no-focus',
+      ];
+
+      log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+
+      const splitProc = crossSpawn(splitArgs, {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const splitExitCode = await splitProc.exited;
+      const splitStdout = await splitProc.stdout();
+      const splitStderr = await splitProc.stderr();
+
+      if (splitExitCode !== 0) {
+        log('[herdr] spawnPane: split failed', {
+          exitCode: splitExitCode,
+          stderr: splitStderr.trim(),
+        });
+        return { success: false };
+      }
+
+      // Parse JSON response to extract pane_id
+      const paneId = parsePaneId(splitStdout);
+      if (!paneId) {
+        log('[herdr] spawnPane: could not parse pane_id from output', {
+          stdout: splitStdout.trim(),
+        });
+        return { success: false };
+      }
+
+      // 2. Rename the pane for visibility
+      await crossSpawn(
+        [herdr, 'pane', 'rename', paneId, description.slice(0, 30)],
+        { stdout: 'ignore', stderr: 'ignore' },
+      ).exited;
+
+      // 3. Run opencode attach in the new pane
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+      );
+
+      log('[herdr] spawnPane: running attach command', {
+        paneId,
+        command: opencodeCmd,
+      });
+
+      const runProc = crossSpawn([herdr, 'pane', 'run', paneId, opencodeCmd], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const runExitCode = await runProc.exited;
+      if (runExitCode !== 0) {
+        const runStderr = await runProc.stderr();
+        log('[herdr] spawnPane: run failed', {
+          exitCode: runExitCode,
+          stderr: runStderr.trim(),
+        });
+        return { success: false };
+      }
+
+      log('[herdr] spawnPane: SUCCESS', { paneId });
+      return { success: true, paneId };
+    } catch (err) {
+      log('[herdr] spawnPane: exception', { error: String(err) });
+      return { success: false };
+    }
+  }
+
+  async closePane(paneId: string): Promise<boolean> {
+    if (!paneId || paneId === 'unknown') return true;
+
+    const herdr = await this.getBinary();
+    if (!herdr) {
+      log('[herdr] closePane: herdr binary not found');
+      return false;
+    }
+
+    try {
+      // Send Ctrl+C for graceful shutdown
+      log('[herdr] closePane: sending Ctrl+C', { paneId });
+      await crossSpawn([herdr, 'pane', 'send-keys', paneId, 'ctrl+c'], {
+        stdout: 'ignore',
+        stderr: 'ignore',
+      }).exited;
+
+      // Wait for graceful shutdown
+      await new Promise((r) => setTimeout(r, 250));
+
+      // Close the pane
+      log('[herdr] closePane: closing pane', { paneId });
+      const proc = crossSpawn([herdr, 'pane', 'close', paneId], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      const stderr = await proc.stderr();
+
+      log('[herdr] closePane: result', { exitCode, stderr: stderr.trim() });
+
+      if (exitCode === 0) {
+        return true;
+      }
+
+      // Pane might already be closed
+      log('[herdr] closePane: failed (pane may already be closed)', {
+        paneId,
+      });
+      return false;
+    } catch (err) {
+      log('[herdr] closePane: exception', { error: String(err) });
+      return false;
+    }
+  }
+
+  async applyLayout(
+    _layout: MultiplexerLayout,
+    _mainPaneSize: number,
+  ): Promise<void> {
+    // No-op for herdr. Herdr does not support tmux-like exact main pane
+    // sizing/rebalancing; layout is applied to future pane creation by
+    // mapping configured layouts to pane split directions.
+  }
+
+  private targetPaneArg(): string[] {
+    return this.parentPaneId ? [this.parentPaneId] : ['--current'];
+  }
+
+  private async getBinary(): Promise<string | null> {
+    await this.isAvailable();
+    return this.binaryPath;
+  }
+
+  private async findBinary(): Promise<string | null> {
+    const cmd = process.platform === 'win32' ? 'where' : 'which';
+
+    try {
+      const proc = crossSpawn([cmd, 'herdr'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) {
+        log("[herdr] findBinary: 'which herdr' failed", { exitCode });
+        return null;
+      }
+
+      const stdout = await proc.stdout();
+      const path = stdout.trim().split('\n')[0];
+      if (!path) {
+        log('[herdr] findBinary: no path in output');
+        return null;
+      }
+
+      log('[herdr] findBinary: found', { path });
+      return path;
+    } catch (err) {
+      log('[herdr] findBinary: exception', { error: String(err) });
+      return null;
+    }
+  }
+}
+
+/**
+ * Parse the pane_id from a herdr CLI JSON response.
+ *
+ * Herdr outputs newline-delimited JSON like:
+ * {"id":"cli:pane:split","result":{"type":"pane_info","pane":{"pane_id":"w1:p2",...}}}
+ */
+function parsePaneId(stdout: string): string | null {
+  const trimmed = stdout.trim();
+  if (!trimmed) return null;
+
+  try {
+    const response = JSON.parse(trimmed) as HerdrCliResponse;
+    return response.result?.pane?.pane_id ?? null;
+  } catch {
+    log('[herdr] parsePaneId: failed to parse JSON', { stdout: trimmed });
+    return null;
+  }
+}
+
+function getPaneDirection(layout: MultiplexerLayout): HerdrPaneDirection {
+  switch (layout) {
+    case 'main-horizontal':
+    case 'even-vertical':
+      return 'down';
+    case 'main-vertical':
+    case 'even-horizontal':
+    case 'tiled':
+      return 'right';
+  }
+}
+
+function buildOpencodeAttachCommand(
+  sessionId: string,
+  serverUrl: string,
+  directory: string,
+): string {
+  return [
+    'opencode',
+    'attach',
+    quoteShellArg(serverUrl),
+    '--session',
+    quoteShellArg(sessionId),
+    '--dir',
+    quoteShellArg(directory),
+  ].join(' ');
+}
+
+function quoteShellArg(value: string): string {
+  return `'${value.replace(/'/g, `'\\''`)}'`;
+}

+ 1 - 0
src/multiplexer/index.ts

@@ -7,6 +7,7 @@ export {
   getMultiplexer,
   startAvailabilityCheck,
 } from './factory';
+export { HerdrMultiplexer } from './herdr';
 export {
   MultiplexerSessionManager,
   TmuxSessionManager,

+ 4 - 4
src/multiplexer/types.ts

@@ -1,8 +1,8 @@
 /**
  * Multiplexer abstraction layer
  *
- * Provides a unified interface for terminal multiplexers (tmux, zellij, etc.)
- * to spawn and manage panes for child agent sessions.
+ * Provides a unified interface for terminal multiplexers (tmux, zellij,
+ * herdr, etc.) to spawn and manage panes for child agent sessions.
  */
 
 import type { MultiplexerConfig, MultiplexerLayout } from '../config/schema';
@@ -14,10 +14,10 @@ export interface PaneResult {
 
 /**
  * Core multiplexer interface
- * Implementations: TmuxMultiplexer, ZellijMultiplexer
+ * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer
  */
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij';
+  readonly type: 'tmux' | 'zellij' | 'herdr';
 
   /**
    * Check if the multiplexer binary is available on the system