Просмотр исходного кода

feat: add native cmux multiplexer support

highcoldddd 2 недель назад
Родитель
Сommit
35713ee196

+ 2 - 2
README.ja-JP.md

@@ -135,7 +135,7 @@ bun run build
 > バックグラウンドオーケストレーションの仕組みを理解しておくことを**推奨**します。**[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 または Herdr ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
+> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux、Zellij、Herdr または cmux ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
 
 デフォルトで生成される設定には `openai` と `opencode-go` の両方のプリセットが含まれます。
 
@@ -630,7 +630,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 や Herdr のペインでライブ表示します |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux、Zellij、Herdr や cmux のペインでライブ表示します |
 | **[Codemap](docs/codemap.md)** | 階層的なコードマップを生成し、大規模コードベースを迅速に理解します |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |

+ 2 - 2
README.ko-KR.md

@@ -133,7 +133,7 @@ bun run build
 > 자동 위임이 어떻게 동작하는지 이해하는 것을 **권장**합니다. **[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, 또는 Herdr 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
+> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux, Zellij, Herdr 또는 cmux 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
 
 기본 생성 설정에는 `openai`와 `opencode-go` 프리셋이 모두 포함되어 있습니다.
 
@@ -628,7 +628,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, 또는 Herdr 페인에서 에이전트 작업을 실시간으로 확인 |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux, Zellij, Herdr 또는 cmux 페인에서 에이전트 작업을 실시간으로 확인 |
 | **[Codemap](docs/codemap.md)** | 계층형 코드맵을 생성하여 대규모 코드베이스를 빠르게 파악 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |

+ 2 - 2
README.md

@@ -132,7 +132,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, Zellij, or Herdr 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, Herdr, or cmux 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.
 
@@ -664,7 +664,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, Zellij, or Herdr panes |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux, Zellij, Herdr, or cmux 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

@@ -128,7 +128,7 @@ bun run build
 > **建议**了解后台编排的工作原理。**[编排者提示词 (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 或 Herdr 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
+> 由于后台智能体现在是默认工作流,**强烈建议**启用并配置 **[Multiplexer Integration](docs/multiplexer-integration.md)**。它会自动在专用的 Tmux、Zellij、Herdr 或 cmux 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
 
 默认生成的配置包含 `openai` 和 `opencode-go` 两个预设:
 
@@ -623,7 +623,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 或 Herdr 窗格中实时观看智能体工作 |
+| **[多路复用器集成](docs/multiplexer-integration.md)** | 在 Tmux、Zellij、Herdr 或 cmux 窗格中实时观看智能体工作 |
 | **[Codemap](docs/codemap.md)** | 生成层级代码地图,更快理解大型代码库 |
 | **[Clonedeps](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[Worktrees](docs/worktrees.md)** | 使用 `.slim/worktrees/` 通道进行隔离的并行或高风险编码工作 |

+ 7 - 2
codemap.md

@@ -48,6 +48,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `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/multiplexer/cmux/` | cmux adapter plus dedicated lifecycle, global state registry, and close policy. | [View Map](src/multiplexer/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) |
@@ -76,7 +77,8 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 3. **Delegated execution**
    - Native OpenCode background tasks are parsed from `task` output and injected completion messages and tracked in the shared background job board.
    - `src/hooks/task-session-manager/` updates job-board state, resolves short aliases, and injects background/reusable job context into the orchestrator prompt.
-   - `src/multiplexer/` optionally mirrors those sessions into tmux/zellij panes.
+   - `src/multiplexer/` optionally mirrors those sessions into tmux, Zellij,
+     Herdr, or cmux panes/surfaces.
    - Results flow back into the parent session through notifications/output polling.
 
 4. **Install/release path**
@@ -90,6 +92,9 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - `src/config/` feeds `src/agents/`, session/delegation utilities, and MCP registration.
 - `src/cli/skills.ts` and `src/cli/custom-skills.ts` bridge install-time skill packaging with runtime permission policy.
 - Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/` for depth tracking, result extraction, task output parsing, and alias state.
+- cmux-specific readiness, retry, orphan, and cleanup state lives under
+  `src/multiplexer/cmux/`; the generic manager delegates cmux events so other
+  multiplexer behavior remains on the upstream path.
 - `src/tools/council.ts` delegates into `src/council/`.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
@@ -114,4 +119,4 @@ This codemap covers the plugin repository itself and excludes the nested `openco
    - `src/multiplexer/codemap.md`
    - `src/tools/codemap.md`
    - `src/hooks/codemap.md`
-4. Relevant subsystem sub-map for the task at hand
+4. Relevant subsystem sub-map for the task at hand

+ 3 - 3
docs/configuration.md

@@ -136,9 +136,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 |
 | `image_routing` | `"auto"` \| `"direct"` | omitted (legacy conditional) | Optional. When omitted, images are intercepted only when Observer is enabled, preserving existing behavior. Explicit `"auto"` requires Observer enabled and saves image attachments to disk before nudging delegation to @observer. `"direct"`: always pass images to the orchestrator. |
 | `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`, `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.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, 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 supported layouts to split directions; cmux maintains a right-hand agent column |
+| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij, Herdr, and cmux |
 | `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` |

+ 68 - 5
docs/multiplexer-integration.md

@@ -1,6 +1,7 @@
 # Multiplexer Integration Guide
 
-Use tmux, Zellij, or Herdr to watch subagents work in live panes while OpenCode keeps running in your main session.
+Use tmux, Zellij, Herdr, or cmux to watch subagents work in live panes while
+OpenCode keeps running in your main session.
 
 ## Table of Contents
 
@@ -93,7 +94,19 @@ Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 }
 ```
 
-### 2. Start OpenCode inside tmux, Zellij, or Herdr
+**cmux only:**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "cmux"
+  }
+}
+```
+
+cmux 0.64.14 or newer is required; 0.64.17 or newer is recommended.
+
+### 2. Start OpenCode inside tmux, Zellij, Herdr, or cmux
 
 **Tmux:**
 
@@ -116,6 +129,9 @@ herdr
 opencode --port 4096
 ```
 
+**cmux:** Start OpenCode in a cmux surface. Auto-detection requires cmux to
+provide `CMUX_SOCKET_PATH`, `CMUX_WORKSPACE_ID`, and `CMUX_SURFACE_ID`.
+
 ### 3. Trigger delegated work
 
 Ask OpenCode to do something that launches subagents. New panes should appear automatically.
@@ -144,9 +160,9 @@ Please analyze this codebase and create a documentation structure.
 
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
-| `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 |
+| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, `"herdr"`, `"cmux"`, or `"none"` |
+| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij/Herdr pane directions where possible; ignored by cmux |
+| `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`); ignored by Zellij, Herdr, and cmux |
 | `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
@@ -156,6 +172,53 @@ 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) |
+| **cmux** | ✅ Supported | Requires cmux 0.64.14+ (0.64.17+ recommended); creates the agent column to the right and stacks subsequent agents downward without moving focus |
+
+The cmux adapter equalizes vertical splits after each successful add and close.
+It always creates the first agent to the right and subsequent agents downward;
+both `layout` and `main_pane_size` are ignored by cmux.
+cmux's workspace-level vertical equalization can affect other vertical
+subtrees, so the adapter assumes its managed right-hand agent column is the
+only vertical subtree in the workspace that should be automatically
+equalized. `layout` and `main_pane_size` do not alter cmux's left/right width.
+
+cmux follows the OpenCode/OMO lifecycle rather than using a placeholder pane.
+Attach commands require an existing absolute OpenCode executable, resolved in
+the order explicit setting, `OPENCODE_BIN`, `process.execPath`, and
+`process.argv[0]`. If none is valid, no surface is created and a bare
+`opencode` command is never emitted.
+
+Activity cancels an idle close, while deletion upgrades it and retries
+immediately. Failed closes and failed startup cleanup retain the encoded pane
+handle and enter a 30-second then 60-second orphan cooldown. Tracking is only
+removed after cmux reports `closed` or `not_found`; bounded disposal cleanup
+retains unresolved orphan records.
+
+Recovery is bounded per lifecycle instance: after its finite close-attempt
+budget is exhausted, the orphan remains in the process-global registry without
+an infinite retry timer. A later cmux lifecycle for the same directory takes
+ownership and receives a fresh finite budget. This registry survives plugin
+hot reloads in the same process, but is not persistent storage and cannot
+recover state after a hard process crash.
+cmux also pins pane attachment to the host OpenCode executable (or a valid
+absolute `OPENCODE_BIN` override) instead of resolving `opencode` from the new
+pane's `PATH`. This prevents a different installed OpenCode version from
+starting and immediately exiting during attach.
+It polls the configured server's `/session/status` endpoint and creates the
+pane only after the child is reported as `idle`, `running`, `busy`, or `retry`.
+Transient network errors, missing statuses, and server startup races are
+bounded waits and therefore do not flash an empty pane. A readiness timeout or
+a recoverable cmux split-capacity/layout error is retried about two seconds
+later, deduplicated by session, for up to five minutes. Deleting the session or
+shutting down the plugin cancels that deferred work.
+
+To avoid closing a pane during transient idle notifications, cmux keeps it for
+at least ten seconds after attachment and requires three consecutive idle
+polls plus a final idle recheck with no intervening activity. Missing status is
+treated as a grace condition. This stability policy is cmux-specific; existing
+tmux, Zellij, and Herdr close behavior is unchanged. Native cmux support does
+not create placeholder panes while waiting and does not currently expose a
+configurable cmux column width.
 
 **Example: open Zellij subagents in the parent OpenCode tab**
 

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

@@ -239,6 +239,7 @@
             "tmux",
             "zellij",
             "herdr",
+            "cmux",
             "none"
           ]
         },

+ 9 - 0
src/config/multiplexer-cmux.test.ts

@@ -0,0 +1,9 @@
+import { describe, expect, test } from 'bun:test';
+import { MultiplexerConfigSchema, MultiplexerTypeSchema } from './schema';
+
+describe('cmux multiplexer schema', () => {
+  test('accepts cmux as a multiplexer type and config', () => {
+    expect(MultiplexerTypeSchema.parse('cmux')).toBe('cmux');
+    expect(MultiplexerConfigSchema.parse({ type: 'cmux' }).type).toBe('cmux');
+  });
+});

+ 1 - 0
src/config/schema.ts

@@ -90,6 +90,7 @@ export const MultiplexerTypeSchema = z.enum([
   'tmux',
   'zellij',
   'herdr',
+  'cmux',
   'none',
 ]);
 export type MultiplexerType = z.infer<typeof MultiplexerTypeSchema>;

+ 4 - 0
src/index.ts

@@ -914,6 +914,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Handle session.deleted events for pane cleanup
       await multiplexerSessionManager.onSessionDeleted(event);
 
+      if (event.type === 'server.instance.disposed') {
+        await multiplexerSessionManager.cleanupOnInstanceDisposed();
+      }
+
       // Runtime model fallback for foreground agents (rate-limit detection)
       await foregroundFallback.handleEvent(input.event);
 

+ 33 - 0
src/multiplexer/cmux/close-policy.test.ts

@@ -0,0 +1,33 @@
+import { describe, expect, test } from 'bun:test';
+import { CmuxClosePolicy } from './close-policy';
+
+describe('CmuxClosePolicy', () => {
+  test('activity cancels idle but not deleted', () => {
+    const policy = new CmuxClosePolicy();
+    expect(policy.activity(policy.request('idle', 1, 0))).toBeUndefined();
+    expect(policy.activity(policy.request('deleted', 1, 0))?.reason).toBe(
+      'deleted',
+    );
+  });
+
+  test('deleted upgrades idle and refreshes its retry budget', () => {
+    const policy = new CmuxClosePolicy(100, 2);
+    const idle = policy.failed(policy.request('idle', 1, 0), 1);
+    const deleted = policy.request('deleted', 2, 50, idle);
+    expect(deleted).toMatchObject({
+      reason: 'deleted',
+      attempts: 0,
+      deadline: 150,
+      nextAttemptAt: 50,
+    });
+  });
+
+  test('exhaustion enters 30 then 60 second tracked cooldown', () => {
+    const policy = new CmuxClosePolicy(100, 1);
+    const first = policy.failed(policy.request('cleanup', 0, 0), 1);
+    expect(first).toMatchObject({ phase: 'cooldown', nextAttemptAt: 30_001 });
+    const second = policy.failed(first, 30_001);
+    expect(second.nextAttemptAt).toBe(90_001);
+    expect(policy.complete()).toBeUndefined();
+  });
+});

+ 53 - 0
src/multiplexer/cmux/close-policy.ts

@@ -0,0 +1,53 @@
+export type CmuxCloseReason = 'idle' | 'deleted' | 'cleanup';
+export interface CmuxCloseIntent {
+  reason: CmuxCloseReason;
+  expectedActivityVersion: number;
+  attempts: number;
+  deadline: number;
+  phase: 'pending' | 'cooldown';
+  nextAttemptAt: number;
+}
+
+export class CmuxClosePolicy {
+  constructor(
+    private readonly budgetMs = 30_000,
+    private readonly maxAttempts = 4,
+  ) {}
+  request(
+    reason: CmuxCloseReason,
+    version: number,
+    now: number,
+    current?: CmuxCloseIntent,
+  ): CmuxCloseIntent {
+    if (!current || (reason === 'deleted' && current.reason === 'idle')) {
+      return {
+        reason,
+        expectedActivityVersion: version,
+        attempts: 0,
+        deadline: now + this.budgetMs,
+        phase: 'pending',
+        nextAttemptAt: now,
+      };
+    }
+    return current;
+  }
+  activity(intent?: CmuxCloseIntent): CmuxCloseIntent | undefined {
+    return intent?.reason === 'idle' ? undefined : intent;
+  }
+  failed(intent: CmuxCloseIntent, now: number): CmuxCloseIntent {
+    const attempts = intent.attempts + 1;
+    if (attempts >= this.maxAttempts || now >= intent.deadline) {
+      const delay = intent.phase === 'cooldown' ? 60_000 : 30_000;
+      return {
+        ...intent,
+        attempts,
+        phase: 'cooldown',
+        nextAttemptAt: now + delay,
+      };
+    }
+    return { ...intent, attempts, nextAttemptAt: now + 1_000 };
+  }
+  complete(): undefined {
+    return undefined;
+  }
+}

+ 454 - 0
src/multiplexer/cmux/index.test.ts

@@ -0,0 +1,454 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import {
+  CliCmuxClient,
+  type CmuxClient,
+  CmuxMultiplexer,
+  type CommandRunner,
+  resetCmuxStateForTests,
+  SpawnCommandRunner,
+} from '.';
+
+function client(): CmuxClient {
+  let next = 1;
+  return {
+    version: mock(async () => '0.64.17'),
+    identify: mock(async () => ({
+      workspaceId: 'workspace-root',
+      paneId: 'pane-root',
+      surfaceId: 'surface-root',
+      socketPath: '/tmp/cmux.sock',
+    })),
+    createSurface: mock(async () => ({
+      paneId: `pane-${next}`,
+      surfaceId: `surface-${next++}`,
+    })),
+    respawnSurface: mock(async () => true),
+    closeSurface: mock(async () => 'closed' as const),
+    equalizeSplits: mock(async () => true),
+  };
+}
+
+function mux(api: CmuxClient): CmuxMultiplexer {
+  return new CmuxMultiplexer(api, {
+    checkSessionReady: async () => true,
+    delay: async () => {},
+    opencodeBinary: '/opt/opencode',
+    pathExists: () => true,
+  });
+}
+
+describe('CmuxMultiplexer', () => {
+  const originalFetch = globalThis.fetch;
+  beforeEach(resetCmuxStateForTests);
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+  });
+
+  test('gates cmux versions', async () => {
+    const api = client();
+    api.version = mock(async () => '0.64.13');
+    expect(await mux(api).isAvailable()).toBe(false);
+    api.version = mock(async () => '0.64.14');
+    expect(await mux(api).isAvailable()).toBe(true);
+  });
+
+  test('does not cache transient version failure but keeps hard old version', async () => {
+    const api = client();
+    api.version = mock(async () => null);
+    const transient = mux(api);
+    expect(await transient.isAvailable()).toBe(false);
+    api.version = mock(async () => '0.64.17');
+    expect(await transient.isAvailable()).toBe(true);
+
+    const old = client();
+    old.version = mock(async () => '0.64.13');
+    expect(
+      await mux(old).spawnPane('old', 'agent', 'http://server', '/repo'),
+    ).toEqual({ success: false, error: 'hard' });
+  });
+
+  test('waits for attachable status before creating and respawning', async () => {
+    const api = client();
+    const events: string[] = [];
+    api.createSurface = mock(async () => {
+      events.push('create');
+      return { paneId: 'pane-1', surfaceId: 'surface-1' };
+    });
+    api.respawnSurface = mock(async () => {
+      events.push('respawn');
+      return true;
+    });
+    let attempt = 0;
+    const readiness = mock(async (url: URL, sessionId: string) => {
+      events.push('status');
+      expect(url.href).toBe('https://example.test/session/status');
+      expect(sessionId).toBe('session-1');
+      attempt += 1;
+      if (attempt === 2) throw new Error('network');
+      return attempt >= 4;
+    });
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: readiness,
+      delay: async () => {},
+      opencodeBinary: '/opt/opencode',
+      pathExists: () => true,
+    });
+    expect(
+      await instance.spawnPane(
+        'session-1',
+        'agent',
+        'https://example.test/base',
+        '/repo',
+      ),
+    ).toEqual(expect.objectContaining({ success: true }));
+    expect(events).toEqual([
+      'status',
+      'status',
+      'status',
+      'status',
+      'create',
+      'respawn',
+    ]);
+  });
+
+  test('readiness timeout does not flash or create a pane', async () => {
+    const api = client();
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: async () => false,
+      delay: async () => {},
+      opencodeBinary: '/opt/opencode',
+      pathExists: () => true,
+    });
+    expect(
+      await instance.spawnPane('s1', 'agent', 'http://server', '/repo'),
+    ).toEqual({ success: false, error: 'unavailable' });
+    expect(api.createSurface).not.toHaveBeenCalled();
+    expect(api.respawnSurface).not.toHaveBeenCalled();
+    expect(api.closeSurface).not.toHaveBeenCalled();
+  });
+
+  test('hanging readiness does not block an existing pane close', async () => {
+    const api = client();
+    const hanging = createDeferred<boolean>();
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: (_url, sessionId) =>
+        sessionId === 'first' ? Promise.resolve(true) : hanging.promise,
+      delay: async () => {},
+      readinessAttemptTimeoutMs: 1,
+      opencodeBinary: '/opt/opencode',
+      pathExists: () => true,
+    });
+    const first = await instance.spawnPane(
+      'first',
+      'agent',
+      'http://server',
+      '/repo',
+    );
+    const waiting = instance.spawnPane(
+      'hanging',
+      'agent',
+      'http://server',
+      '/repo',
+    );
+    expect(await instance.closePane(first.paneId ?? '')).toBe(true);
+    expect(api.closeSurface).toHaveBeenCalledWith(
+      'workspace-root',
+      'surface-1',
+    );
+    expect(await waiting).toEqual({ success: false, error: 'unavailable' });
+  });
+
+  test('default readiness parses the target status from /session/status', async () => {
+    const api = client();
+    const requested: string[] = [];
+    globalThis.fetch = mock(async (input) => {
+      requested.push(String(input));
+      return Response.json({ target: { type: 'busy' } });
+    }) as typeof fetch;
+    expect(
+      await new CmuxMultiplexer(api, {
+        opencodeBinary: '/opt/opencode',
+        pathExists: () => true,
+      }).spawnPane('target', 'agent', 'http://127.0.0.1:7777/base', '/repo'),
+    ).toEqual(expect.objectContaining({ success: true }));
+    expect(requested).toEqual(['http://127.0.0.1:7777/session/status']);
+  });
+
+  test('uses stable create IDs for right/down anchors and close', async () => {
+    const api = client();
+    const instance = mux(api);
+    const first = await instance.spawnPane('s1', 'one', 'http://server', '/r');
+    await instance.spawnPane('s2', 'two', 'http://server', '/r');
+    expect(api.createSurface).toHaveBeenNthCalledWith(1, {
+      workspaceId: 'workspace-root',
+      targetSurfaceId: 'surface-root',
+      direction: 'right',
+      focus: false,
+    });
+    expect(api.createSurface).toHaveBeenNthCalledWith(
+      2,
+      expect.objectContaining({
+        targetSurfaceId: 'surface-1',
+        direction: 'down',
+      }),
+    );
+    await instance.closePane(first.paneId ?? '');
+    expect(api.closeSurface).toHaveBeenCalledWith(
+      'workspace-root',
+      'surface-1',
+    );
+    expect(api.equalizeSplits).toHaveBeenCalledTimes(3);
+  });
+
+  test('serializes concurrent spawns', async () => {
+    const api = client();
+    const instance = mux(api);
+    await Promise.all([
+      instance.spawnPane('s1', 'one', 'http://server', '/repo'),
+      instance.spawnPane('s2', 'two', 'http://server', '/repo'),
+    ]);
+    expect(api.createSurface).toHaveBeenNthCalledWith(
+      2,
+      expect.objectContaining({ direction: 'down' }),
+    );
+  });
+
+  test('quotes attach command data safely', async () => {
+    const api = client();
+    await mux(api).spawnPane(
+      "s'$(touch /tmp/no);",
+      'one',
+      'http://host/a b;$()',
+      "/repo/a b/'quoted'",
+    );
+    expect(api.respawnSurface).toHaveBeenCalledWith(
+      'workspace-root',
+      'surface-1',
+      "'/opt/opencode' attach 'http://host/a b;$()' --session 's'\\''$(touch /tmp/no);' --dir '/repo/a b/'\\''quoted'\\'''",
+    );
+  });
+
+  test('uses the injected host opencode executable instead of pane PATH', async () => {
+    const api = client();
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: async () => true,
+      delay: async () => {},
+      opencodeBinary: '/Users/king/.opencode/bin/opencode',
+      pathExists: () => true,
+    });
+    await instance.spawnPane('s1', 'agent', 'http://server', '/repo');
+    expect(api.respawnSurface).toHaveBeenCalledWith(
+      'workspace-root',
+      'surface-1',
+      "'/Users/king/.opencode/bin/opencode' attach 'http://server' --session 's1' --dir '/repo'",
+    );
+  });
+
+  test('quotes an injected host executable containing spaces', async () => {
+    const api = client();
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: async () => true,
+      delay: async () => {},
+      opencodeBinary: '/Applications/Open Code/opencode',
+      pathExists: () => true,
+    });
+    await instance.spawnPane('s1', 'agent', 'http://server', '/repo');
+    const command = (api.respawnSurface as ReturnType<typeof mock>).mock
+      .calls[0]?.[2];
+    expect(command).toStartWith("'/Applications/Open Code/opencode' attach");
+  });
+
+  test('returns an orphan handle when respawn and cleanup both fail', async () => {
+    const api = client();
+    api.respawnSurface = mock(async () => false);
+    api.closeSurface = mock(async () => 'failed' as const);
+    const result = await mux(api).spawnPane(
+      'orphan',
+      'agent',
+      'http://server',
+      '/repo',
+    );
+    expect(result).toEqual({
+      success: false,
+      error: 'hard',
+      orphanPaneId: expect.stringContaining('cmux:v1:'),
+    });
+    expect(await mux(api).closePane(result.orphanPaneId ?? '')).toBe(false);
+  });
+
+  test('does not create a surface without an existing absolute binary', async () => {
+    const api = client();
+    const instance = new CmuxMultiplexer(api, {
+      checkSessionReady: async () => true,
+      opencodeBinary: 'opencode',
+      pathExists: () => false,
+    });
+    expect(
+      await instance.spawnPane('s', 'agent', 'http://server', '/repo'),
+    ).toEqual({ success: false, error: 'hard' });
+    expect(api.createSurface).not.toHaveBeenCalled();
+    expect(api.respawnSurface).not.toHaveBeenCalled();
+  });
+});
+
+describe('CliCmuxClient', () => {
+  test('bounds a hanging spawned command', async () => {
+    const kill = mock(() => true);
+    const runner = new SpawnCommandRunner(5, (() => ({
+      exited: new Promise<number>(() => {}),
+      stdout: async () => '',
+      stderr: async () => '',
+      kill,
+    })) as any);
+    const result = await runner.run(['/bin/cmux', '--version']);
+    expect(result.exitCode).toBe(124);
+    expect(result.stderr).toContain('unavailable');
+    expect(kill).toHaveBeenCalledWith('SIGTERM');
+  });
+
+  test('uses the 0.64.14 UUID CLI contract', async () => {
+    const calls: string[][] = [];
+    const runner: CommandRunner = {
+      run: mock(async (argv) => {
+        calls.push(argv);
+        return {
+          exitCode: 0,
+          stderr: '',
+          stdout: argv.includes('identify')
+            ? JSON.stringify({
+                socket_path: '/tmp/cmux.sock',
+                caller: {
+                  workspace_id: 'w',
+                  pane_id: 'p',
+                  surface_id: 's',
+                },
+              })
+            : JSON.stringify({ pane_id: 'p2', surface_id: 's2' }),
+        };
+      }),
+    };
+    const cli = new CliCmuxClient(runner, '/bin/cmux');
+    expect(await cli.identify()).toEqual({
+      workspaceId: 'w',
+      paneId: 'p',
+      surfaceId: 's',
+      socketPath: '/tmp/cmux.sock',
+    });
+    await cli.createSurface({
+      workspaceId: 'w',
+      targetSurfaceId: 's',
+      direction: 'right',
+      focus: false,
+    });
+    expect(calls[1]).toEqual([
+      '/bin/cmux',
+      '--json',
+      '--id-format',
+      'uuids',
+      'new-split',
+      'right',
+      '--workspace',
+      'w',
+      '--surface',
+      's',
+      '--focus',
+      'false',
+    ]);
+  });
+
+  test('covers respawn close equalize and resets create error classification', async () => {
+    const calls: string[][] = [];
+    let splitAttempt = 0;
+    const runner: CommandRunner = {
+      run: mock(async (argv) => {
+        calls.push(argv);
+        if (argv.includes('new-split')) {
+          splitAttempt += 1;
+          return splitAttempt === 1
+            ? { exitCode: 1, stdout: '', stderr: 'invalid_state' }
+            : { exitCode: 1, stdout: '', stderr: 'bad configuration' };
+        }
+        if (argv.includes('close-surface')) {
+          return { exitCode: 1, stdout: '', stderr: 'not_found' };
+        }
+        return { exitCode: 0, stdout: '', stderr: '' };
+      }),
+    };
+    const cli = new CliCmuxClient(runner, '/bin/cmux');
+    const input = {
+      workspaceId: 'w',
+      targetSurfaceId: 's',
+      direction: 'right' as const,
+      focus: false as const,
+    };
+    expect(await cli.createSurface(input)).toBeNull();
+    expect(cli.getCreateError()).toBe('invalid_state');
+    expect(await cli.createSurface(input)).toBeNull();
+    expect(cli.getCreateError()).toBe('hard');
+    expect(await cli.respawnSurface('w', 's', 'safe command')).toBe(true);
+    expect(await cli.closeSurface('w', 's')).toBe('not_found');
+    expect(
+      await cli.equalizeSplits({ workspace_id: 'w', orientation: 'vertical' }),
+    ).toBe(true);
+    expect(calls.at(-2)).toEqual([
+      '/bin/cmux',
+      'close-surface',
+      '--workspace',
+      'w',
+      '--surface',
+      's',
+    ]);
+  });
+
+  test('classifies runner throws as transient and recovers without stale errors', async () => {
+    let calls = 0;
+    const runner: CommandRunner = {
+      run: mock(async (argv) => {
+        calls += 1;
+        if (calls <= 3) throw new Error('socket closed');
+        if (argv.includes('--version')) {
+          return { exitCode: 0, stdout: 'cmux 0.64.17', stderr: '' };
+        }
+        if (argv.includes('identify')) {
+          return {
+            exitCode: 0,
+            stderr: '',
+            stdout: JSON.stringify({
+              socket_path: '/tmp/cmux.sock',
+              caller: { workspace_id: 'w', pane_id: 'p', surface_id: 's' },
+            }),
+          };
+        }
+        return {
+          exitCode: 0,
+          stderr: '',
+          stdout: JSON.stringify({ pane_id: 'p2', surface_id: 's2' }),
+        };
+      }),
+    };
+    const cli = new CliCmuxClient(runner, '/bin/cmux');
+    expect(await cli.version()).toBeNull();
+    expect(cli.getVersionError()).toBe('unavailable');
+    expect(await cli.identify()).toBeNull();
+    expect(cli.getIdentifyError()).toBe('unavailable');
+    expect(
+      await cli.createSurface({
+        workspaceId: 'w',
+        targetSurfaceId: 's',
+        direction: 'right',
+        focus: false,
+      }),
+    ).toBeNull();
+    expect(cli.getCreateError()).toBe('unavailable');
+    expect(await cli.version()).toBe('0.64.17');
+    expect(await cli.identify()).not.toBeNull();
+  });
+});
+
+function createDeferred<T>() {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((res) => {
+    resolve = res;
+  });
+  return { promise, resolve };
+}

+ 753 - 0
src/multiplexer/cmux/index.ts

@@ -0,0 +1,753 @@
+import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
+import { log } from '../../utils/logger';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  resolveHostOpencodeBinary,
+} from '../shared';
+import type { Multiplexer, PaneResult } from '../types';
+
+const MINIMUM_VERSION = '0.64.14';
+const READINESS_DELAYS_MS = [50, 100, 200, 400, 500, 500, 250] as const;
+
+export interface CmuxReadinessOptions {
+  checkSessionReady?: (
+    url: URL,
+    sessionId: string,
+    signal: AbortSignal,
+  ) => Promise<boolean>;
+  delay?: (milliseconds: number) => Promise<void>;
+  readinessAttemptTimeoutMs?: number;
+  opencodeBinary?: string;
+  pathExists?: (path: string) => boolean;
+}
+
+export interface CommandResult {
+  exitCode: number;
+  stdout: string;
+  stderr: string;
+}
+
+export interface CommandRunner {
+  run(argv: string[]): Promise<CommandResult>;
+}
+
+export interface CmuxIdentity {
+  workspaceId: string;
+  paneId: string;
+  surfaceId: string;
+  socketPath: string;
+}
+
+export interface CmuxClient {
+  version(): Promise<string | null>;
+  getVersionError?(): 'unavailable' | 'hard';
+  identify(): Promise<CmuxIdentity | null>;
+  getIdentifyError?(): 'unavailable' | 'hard';
+  createSurface(input: {
+    workspaceId: string;
+    targetSurfaceId: string;
+    direction: 'right' | 'down';
+    focus: false;
+  }): Promise<{ paneId: string; surfaceId: string } | null>;
+  getCreateError?(): 'not_found' | 'unavailable' | 'invalid_state' | 'hard';
+  respawnSurface(
+    workspaceId: string,
+    surfaceId: string,
+    command: string,
+  ): Promise<boolean>;
+  closeSurface(
+    workspaceId: string,
+    surfaceId: string,
+  ): Promise<'closed' | 'not_found' | 'failed'>;
+  equalizeSplits(params: {
+    workspace_id: string;
+    orientation: 'vertical';
+  }): Promise<boolean>;
+}
+
+interface Handle {
+  v: 1;
+  socketPath: string;
+  workspaceId: string;
+  paneId: string;
+  surfaceId: string;
+}
+
+interface Registry {
+  root: CmuxIdentity;
+  agents: Handle[];
+}
+
+const registries = new Map<string, Registry>();
+let mutationQueue = Promise.resolve();
+let mutationSequence = 0;
+
+export class CmuxMultiplexer implements Multiplexer {
+  readonly type = 'cmux' as const;
+  private versionAvailable = false;
+  private availabilityError: 'unavailable' | 'hard' = 'unavailable';
+
+  private readonly checkSessionReady: (
+    url: URL,
+    sessionId: string,
+    signal: AbortSignal,
+  ) => Promise<boolean>;
+  private readonly delay: (milliseconds: number) => Promise<void>;
+  private readonly readinessAttemptTimeoutMs: number;
+  private readonly opencodeBinary: string | null;
+
+  constructor(
+    private readonly client: CmuxClient = new CliCmuxClient(),
+    options: CmuxReadinessOptions = {},
+  ) {
+    this.checkSessionReady = options.checkSessionReady ?? defaultSessionReady;
+    this.delay = options.delay ?? defaultDelay;
+    this.readinessAttemptTimeoutMs = options.readinessAttemptTimeoutMs ?? 1_000;
+    this.opencodeBinary = resolveHostOpencodeBinary({
+      override: options.opencodeBinary,
+      pathExists: options.pathExists,
+    });
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.versionAvailable) return true;
+    const version = await this.client.version().catch(() => null);
+    if (version && compareVersions(version, MINIMUM_VERSION) >= 0) {
+      this.versionAvailable = true;
+      return true;
+    }
+    this.availabilityError = version
+      ? 'hard'
+      : (this.client.getVersionError?.() ?? 'unavailable');
+    return false;
+  }
+
+  isInsideSession(): boolean {
+    return Boolean(
+      process.env.CMUX_SOCKET_PATH &&
+        process.env.CMUX_WORKSPACE_ID &&
+        process.env.CMUX_SURFACE_ID,
+    );
+  }
+
+  async spawnPane(
+    sessionId: string,
+    _description: string,
+    serverUrl: string,
+    directory: string,
+  ): Promise<PaneResult> {
+    if (!this.opencodeBinary) return { success: false, error: 'hard' };
+    const opencodeBinary = this.opencodeBinary;
+    const statusUrl = new URL('/session/status', serverUrl);
+    if (!(await this.waitForSession(statusUrl, sessionId))) {
+      log('[cmux] spawnPane failed', {
+        stage: 'readinessTimeout',
+        sessionId,
+      });
+      return { success: false, error: 'unavailable' };
+    }
+    return enqueueMutation('spawn', async (sequence) => {
+      if (!(await this.isAvailable())) {
+        log('[cmux] spawnPane failed', {
+          sequence,
+          stage: 'version',
+          sessionId,
+        });
+        return {
+          success: false,
+          error: this.availabilityError,
+        };
+      }
+      const root = await this.client.identify();
+      if (!root) {
+        log('[cmux] spawnPane failed', {
+          sequence,
+          stage: 'identify',
+          sessionId,
+        });
+        return {
+          success: false,
+          error: this.client.getIdentifyError?.() ?? 'unavailable',
+        };
+      }
+      const key = registryKey(root.socketPath, root.workspaceId);
+      const registry = registries.get(key) ?? { root, agents: [] };
+      registries.set(key, registry);
+      const previous = registry.agents.at(-1);
+      const targetSurfaceId = previous?.surfaceId ?? registry.root.surfaceId;
+      const direction = previous ? 'down' : 'right';
+      const created = await this.client.createSurface({
+        workspaceId: root.workspaceId,
+        targetSurfaceId,
+        direction,
+        focus: false,
+      });
+      if (!created) {
+        log('[cmux] spawnPane failed', {
+          sequence,
+          stage: 'createSurface',
+          sessionId,
+          workspaceId: root.workspaceId,
+          targetSurfaceId,
+          direction,
+        });
+        return {
+          success: false,
+          error: this.client.getCreateError?.() ?? 'hard',
+        };
+      }
+
+      const handle: Handle = {
+        v: 1,
+        socketPath: root.socketPath,
+        workspaceId: root.workspaceId,
+        paneId: created.paneId,
+        surfaceId: created.surfaceId,
+      };
+      const encodedHandle = encodeHandle(handle);
+
+      const command = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+        opencodeBinary,
+      );
+      try {
+        const started = await this.client.respawnSurface(
+          root.workspaceId,
+          created.surfaceId,
+          command,
+        );
+        if (!started) {
+          log('[cmux] spawnPane failed', {
+            sequence,
+            stage: 'respawn',
+            sessionId,
+            workspaceId: root.workspaceId,
+            targetSurfaceId,
+            direction,
+          });
+          throw new Error('cmux respawn-pane failed');
+        }
+      } catch (error) {
+        log('[cmux] spawnPane respawn exception', {
+          sequence,
+          stage: 'respawn',
+          sessionId,
+          workspaceId: root.workspaceId,
+          targetSurfaceId,
+          direction,
+          errorType: errorName(error),
+        });
+        const cleaned = await this.cleanupPane(
+          root.workspaceId,
+          created.surfaceId,
+        );
+        return cleaned
+          ? { success: false, error: 'hard' }
+          : { success: false, error: 'hard', orphanPaneId: encodedHandle };
+      }
+
+      registry.agents.push(handle);
+      await this.equalize(root.workspaceId);
+      return { success: true, paneId: encodeHandle(handle) };
+    });
+  }
+
+  closePane(paneId: string): Promise<boolean> {
+    const handle = decodeHandle(paneId);
+    if (!handle) return Promise.resolve(false);
+    return enqueueMutation('close', async () => {
+      if (!(await this.isAvailable())) return false;
+      const result = await this.client.closeSurface(
+        handle.workspaceId,
+        handle.surfaceId,
+      );
+      if (result === 'failed') return false;
+      const key = registryKey(handle.socketPath, handle.workspaceId);
+      const registry = registries.get(key);
+      if (registry) {
+        const index = registry.agents.findIndex(
+          (agent) => agent.surfaceId === handle.surfaceId,
+        );
+        if (index >= 0) registry.agents.splice(index, 1);
+        if (registry.agents.length === 0) registries.delete(key);
+      }
+      await this.equalize(handle.workspaceId);
+      return true;
+    });
+  }
+
+  async applyLayout(
+    _layout: MultiplexerLayout,
+    _mainPaneSize: number,
+  ): Promise<void> {
+    // cmux layout is maintained by spawnPane and closePane.
+  }
+
+  private async cleanupPane(
+    workspaceId: string,
+    surfaceId: string,
+  ): Promise<boolean> {
+    try {
+      const result = await this.client.closeSurface(workspaceId, surfaceId);
+      if (result === 'failed') {
+        log('[cmux] failed to close pre-respawn surface', {
+          workspaceId,
+          surfaceId,
+        });
+        return false;
+      }
+      return true;
+    } catch (error) {
+      log('[cmux] failed to close pre-respawn surface', {
+        workspaceId,
+        surfaceId,
+        errorType: errorName(error),
+      });
+      return false;
+    }
+  }
+
+  private async waitForSession(url: URL, sessionId: string): Promise<boolean> {
+    for (let attempt = 0; attempt <= READINESS_DELAYS_MS.length; attempt++) {
+      const controller = new AbortController();
+      const timeout = setTimeout(
+        () => controller.abort(),
+        this.readinessAttemptTimeoutMs,
+      );
+      timeout.unref?.();
+      try {
+        if (
+          await Promise.race([
+            this.checkSessionReady(url, sessionId, controller.signal),
+            new Promise<boolean>((resolve) =>
+              controller.signal.addEventListener(
+                'abort',
+                () => resolve(false),
+                {
+                  once: true,
+                },
+              ),
+            ),
+          ])
+        ) {
+          return true;
+        }
+      } catch {
+        // A session can briefly be unreachable while OpenCode publishes it.
+      } finally {
+        clearTimeout(timeout);
+      }
+      const delay = READINESS_DELAYS_MS[attempt];
+      if (delay === undefined) return false;
+      await this.delay(delay);
+    }
+    return false;
+  }
+
+  private async equalize(workspaceId: string): Promise<void> {
+    try {
+      const success = await this.client.equalizeSplits({
+        workspace_id: workspaceId,
+        orientation: 'vertical',
+      });
+      if (!success)
+        log('[cmux] workspace.equalize_splits failed', { workspaceId });
+    } catch (error) {
+      log('[cmux] workspace.equalize_splits failed', {
+        workspaceId,
+        error: String(error),
+      });
+    }
+  }
+}
+
+export class SpawnCommandRunner implements CommandRunner {
+  constructor(
+    private readonly timeoutMs = 5_000,
+    private readonly spawn: typeof crossSpawn = crossSpawn,
+  ) {}
+
+  async run(argv: string[]): Promise<CommandResult> {
+    const proc = this.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
+    let timeout: ReturnType<typeof setTimeout> | undefined;
+    try {
+      return await Promise.race([
+        Promise.all([proc.exited, proc.stdout(), proc.stderr()]).then(
+          ([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr }),
+        ),
+        new Promise<CommandResult>((resolve) => {
+          timeout = setTimeout(() => {
+            proc.kill('SIGTERM');
+            resolve({
+              exitCode: 124,
+              stdout: '',
+              stderr: 'unavailable: cmux command timed out',
+            });
+          }, this.timeoutMs);
+          timeout.unref?.();
+        }),
+      ]);
+    } finally {
+      if (timeout) clearTimeout(timeout);
+    }
+  }
+}
+
+export class CliCmuxClient implements CmuxClient {
+  private binary: string | null = null;
+  private versionError: 'unavailable' | 'hard' = 'unavailable';
+  private identifyError: 'unavailable' | 'hard' = 'unavailable';
+  private lastRunThrew = false;
+  private createError: 'not_found' | 'unavailable' | 'invalid_state' | 'hard' =
+    'hard';
+
+  constructor(
+    private readonly runner: CommandRunner = new SpawnCommandRunner(),
+    binary?: string,
+  ) {
+    this.binary = binary ?? null;
+  }
+
+  async version(): Promise<string | null> {
+    this.versionError = 'unavailable';
+    const result = await this.run(['--version']);
+    if (!result || result.exitCode !== 0) {
+      this.versionError = 'unavailable';
+      return null;
+    }
+    const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
+    this.versionError = 'hard';
+    return version;
+  }
+
+  getVersionError(): 'unavailable' | 'hard' {
+    return this.versionError;
+  }
+
+  async identify(): Promise<CmuxIdentity | null> {
+    this.identifyError = 'unavailable';
+    const result = await this.run(['--id-format', 'uuids', 'identify']);
+    if (!result || result.exitCode !== 0) {
+      this.identifyError = 'unavailable';
+      return null;
+    }
+    const value = asRecord(parseJson(result.stdout));
+    const caller = asRecord(value?.caller);
+    const focused = asRecord(value?.focused);
+    const workspaceId =
+      stringField(caller, 'workspace_id') ??
+      stringField(focused, 'workspace_id');
+    const paneId =
+      stringField(caller, 'pane_id') ?? stringField(focused, 'pane_id');
+    const surfaceId =
+      stringField(caller, 'surface_id') ?? stringField(focused, 'surface_id');
+    const socketPath = stringField(value, 'socket_path');
+    if (workspaceId && paneId && surfaceId && socketPath) {
+      return { workspaceId, paneId, surfaceId, socketPath };
+    }
+    log('[cmux] response parse failed', {
+      operation: 'identify',
+      reason: value ? 'missing_fields' : 'invalid_json',
+      stdoutLength: result.stdout.length,
+    });
+    this.identifyError = 'hard';
+    return null;
+  }
+
+  getIdentifyError(): 'unavailable' | 'hard' {
+    return this.identifyError;
+  }
+
+  async createSurface(input: {
+    workspaceId: string;
+    targetSurfaceId: string;
+    direction: 'right' | 'down';
+    focus: false;
+  }): Promise<{ paneId: string; surfaceId: string } | null> {
+    this.createError = 'hard';
+    const result = await this.run([
+      '--json',
+      '--id-format',
+      'uuids',
+      'new-split',
+      input.direction,
+      '--workspace',
+      input.workspaceId,
+      '--surface',
+      input.targetSurfaceId,
+      '--focus',
+      'false',
+    ]);
+    if (!result || result.exitCode !== 0) {
+      this.createError = this.lastRunThrew
+        ? 'unavailable'
+        : classifyCreateError(result?.stderr ?? '');
+      return null;
+    }
+    const value = asRecord(parseJson(result.stdout));
+    const resultValue = asRecord(value?.result);
+    const pane = asRecord(resultValue?.pane);
+    const paneId =
+      stringField(value, 'pane_id') ??
+      stringField(resultValue, 'pane_id') ??
+      stringField(pane, 'pane_id');
+    const surfaceId =
+      stringField(value, 'surface_id') ??
+      stringField(resultValue, 'surface_id') ??
+      stringField(pane, 'surface_id');
+    if (paneId && surfaceId) return { paneId, surfaceId };
+    log('[cmux] response parse failed', {
+      operation: 'new-split',
+      reason: value ? 'missing_fields' : 'invalid_json',
+      stdoutLength: result.stdout.length,
+    });
+    return null;
+  }
+
+  getCreateError(): 'not_found' | 'unavailable' | 'invalid_state' | 'hard' {
+    return this.createError;
+  }
+
+  async respawnSurface(
+    workspaceId: string,
+    surfaceId: string,
+    command: string,
+  ): Promise<boolean> {
+    const result = await this.run([
+      'respawn-pane',
+      '--workspace',
+      workspaceId,
+      '--surface',
+      surfaceId,
+      '--command',
+      command,
+    ]);
+    return result?.exitCode === 0;
+  }
+
+  async closeSurface(
+    workspaceId: string,
+    surfaceId: string,
+  ): Promise<'closed' | 'not_found' | 'failed'> {
+    const result = await this.run([
+      'close-surface',
+      '--workspace',
+      workspaceId,
+      '--surface',
+      surfaceId,
+    ]);
+    if (result?.exitCode === 0) return 'closed';
+    return result?.stderr.toLowerCase().includes('not_found') ||
+      result?.stderr.toLowerCase().includes('not found')
+      ? 'not_found'
+      : 'failed';
+  }
+
+  async equalizeSplits(params: {
+    workspace_id: string;
+    orientation: 'vertical';
+  }): Promise<boolean> {
+    const result = await this.run([
+      'rpc',
+      'workspace.equalize_splits',
+      JSON.stringify(params),
+    ]);
+    return result?.exitCode === 0;
+  }
+
+  private async run(args: string[]): Promise<CommandResult | null> {
+    this.lastRunThrew = false;
+    this.binary ??= await findBinary('cmux');
+    if (!this.binary) return null;
+    let result: CommandResult;
+    try {
+      result = await this.runner.run([this.binary, ...args]);
+    } catch (error) {
+      this.lastRunThrew = true;
+      log('[cmux] command threw', {
+        operation: commandOperation(args),
+        errorType: errorName(error),
+      });
+      return null;
+    }
+    if (result.exitCode !== 0) {
+      const operation = commandOperation(args);
+      log('[cmux] command failed', {
+        operation,
+        exitCode: result.exitCode,
+        stderr:
+          operation === 'respawn-pane'
+            ? '[redacted: may contain attach command]'
+            : safeSummary(result.stderr),
+      });
+    }
+    return result;
+  }
+}
+
+export function resetCmuxStateForTests(): void {
+  registries.clear();
+  mutationQueue = Promise.resolve();
+  mutationSequence = 0;
+}
+
+function enqueueMutation<T>(
+  operation: 'spawn' | 'close',
+  mutation: (sequence: number) => Promise<T>,
+): Promise<T> {
+  const sequence = ++mutationSequence;
+  log('[cmux] mutation enqueue', {
+    sequence,
+    operation,
+    agentCount: registryAgentCount(),
+  });
+  const run = async (): Promise<T> => {
+    log('[cmux] mutation start', {
+      sequence,
+      operation,
+      agentCount: registryAgentCount(),
+    });
+    try {
+      return await mutation(sequence);
+    } finally {
+      log('[cmux] mutation end', {
+        sequence,
+        operation,
+        agentCount: registryAgentCount(),
+      });
+    }
+  };
+  const result = mutationQueue.then(run, run);
+  mutationQueue = result.then(
+    () => undefined,
+    () => undefined,
+  );
+  return result;
+}
+
+function registryAgentCount(): number {
+  let count = 0;
+  for (const registry of registries.values()) count += registry.agents.length;
+  return count;
+}
+
+function commandOperation(args: string[]): string {
+  return (
+    args.find((arg) =>
+      [
+        'identify',
+        'new-split',
+        'respawn-pane',
+        'close-surface',
+        'rpc',
+      ].includes(arg),
+    ) ?? 'version'
+  );
+}
+
+function safeSummary(value: string): string {
+  const trimmed = value.trim();
+  return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;
+}
+
+function classifyCreateError(
+  stderr: string,
+): 'not_found' | 'unavailable' | 'invalid_state' | 'hard' {
+  const normalized = stderr.toLowerCase();
+  if (normalized.includes('not_found') || normalized.includes('not found')) {
+    return 'not_found';
+  }
+  if (normalized.includes('unavailable')) return 'unavailable';
+  if (
+    normalized.includes('invalid_state') ||
+    normalized.includes('invalid state')
+  ) {
+    return 'invalid_state';
+  }
+  return 'hard';
+}
+
+function errorName(error: unknown): string {
+  return error instanceof Error ? error.name : typeof error;
+}
+
+async function defaultSessionReady(
+  url: URL,
+  sessionId: string,
+  signal: AbortSignal,
+): Promise<boolean> {
+  const response = await fetch(url, { signal });
+  if (!response.ok) return false;
+  const statuses = (await response.json()) as Record<
+    string,
+    { type?: string } | undefined
+  >;
+  return ['idle', 'running', 'busy', 'retry'].includes(
+    statuses[sessionId]?.type ?? '',
+  );
+}
+
+function defaultDelay(milliseconds: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+function registryKey(socketPath: string, workspaceId: string): string {
+  return `${socketPath}\0${workspaceId}`;
+}
+
+function encodeHandle(handle: Handle): string {
+  return `cmux:v1:${Buffer.from(JSON.stringify(handle)).toString('base64url')}`;
+}
+
+function decodeHandle(value: string): Handle | null {
+  if (!value.startsWith('cmux:v1:')) return null;
+  try {
+    const parsed = JSON.parse(
+      Buffer.from(value.slice('cmux:v1:'.length), 'base64url').toString(),
+    ) as Partial<Handle>;
+    return parsed.v === 1 &&
+      typeof parsed.socketPath === 'string' &&
+      typeof parsed.workspaceId === 'string' &&
+      typeof parsed.paneId === 'string' &&
+      typeof parsed.surfaceId === 'string'
+      ? (parsed as Handle)
+      : null;
+  } catch {
+    return null;
+  }
+}
+
+function compareVersions(left: string, right: string): number {
+  const a = left.split('.').map(Number);
+  const b = right.split('.').map(Number);
+  for (let index = 0; index < 3; index++) {
+    if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
+  }
+  return 0;
+}
+
+function parseJson(stdout: string): unknown {
+  try {
+    return JSON.parse(stdout);
+  } catch {
+    return null;
+  }
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object'
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function stringField(
+  value: Record<string, unknown> | null,
+  field: string,
+): string | null {
+  const candidate = value?.[field];
+  return typeof candidate === 'string' ? candidate : null;
+}

+ 120 - 0
src/multiplexer/cmux/session-lifecycle.test.ts

@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import type { Multiplexer } from '../types';
+import { CmuxSessionLifecycle } from './session-lifecycle';
+import { CmuxSessionStore } from './session-state';
+
+function deferred<T>() {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((done) => (resolve = done));
+  return { promise, resolve };
+}
+
+function multiplexer() {
+  return {
+    type: 'cmux',
+    isAvailable: async () => true,
+    isInsideSession: () => true,
+    spawnPane: mock(async () => ({ success: true, paneId: 'pane' })),
+    closePane: mock(async () => true),
+    applyLayout: async () => {},
+  } satisfies Multiplexer;
+}
+
+describe('CmuxSessionLifecycle races', () => {
+  const store = new CmuxSessionStore();
+  beforeEach(() => store.resetForTests());
+
+  test('in-flight activity automatically respawns after idle close succeeds', async () => {
+    const mux = multiplexer();
+    const close = deferred<boolean>();
+    mux.closePane.mockImplementationOnce(() => close.promise);
+    const lifecycle = new CmuxSessionLifecycle(
+      'owner',
+      mux,
+      'http://server',
+      '/repo',
+      undefined,
+      { isServerRunning: async () => true },
+    );
+    await lifecycle.onSessionCreated({
+      type: 'session.created',
+      properties: { info: { id: 's', parentID: 'p' } },
+    });
+    const closing = lifecycle.closeSessionFromCoordinator('s');
+    await lifecycle.onSessionStatus({
+      type: 'session.status',
+      properties: { sessionID: 's', status: { type: 'busy' } },
+    });
+    close.resolve(true);
+    await closing;
+    expect(mux.spawnPane).toHaveBeenCalledTimes(2);
+    expect(store.get('s')).toMatchObject({
+      paneId: 'pane',
+      spawnState: 'attached',
+      lifecycle: 'active',
+      owner: 'owner',
+    });
+  });
+
+  test('dispose gate closes a late successful spawn and never marks it active', async () => {
+    const mux = multiplexer();
+    const spawn = deferred<{ success: true; paneId: string }>();
+    mux.spawnPane.mockImplementationOnce(() => spawn.promise);
+    const lifecycle = new CmuxSessionLifecycle(
+      'owner',
+      mux,
+      'http://server',
+      '/repo',
+      undefined,
+      {
+        delay: async () => {},
+        shutdownTimeoutMs: 1,
+        isServerRunning: async () => true,
+      },
+    );
+    const creating = lifecycle.onSessionCreated({
+      type: 'session.created',
+      properties: { info: { id: 'late', parentID: 'p' } },
+    });
+    await Promise.resolve();
+    await lifecycle.cleanup();
+    spawn.resolve({ success: true, paneId: 'late-pane' });
+    await creating;
+    expect(mux.closePane).toHaveBeenCalledWith('late-pane');
+    expect(store.get('late')).toBeUndefined();
+    await lifecycle.onSessionCreated({
+      type: 'session.created',
+      properties: { info: { id: 'blocked', parentID: 'p' } },
+    });
+    expect(mux.spawnPane).toHaveBeenCalledTimes(1);
+  });
+
+  test('new same-directory lifecycle takes over orphan with bounded attempts', async () => {
+    store.claimCreated({
+      session: 'orphan',
+      owner: 'old',
+      parent: 'p',
+      title: 'agent',
+      directory: '/repo',
+      paneId: 'orphan-pane',
+      spawnState: 'attached',
+      lifecycle: 'orphaned',
+      lastActivityAt: 0,
+      activityVersion: 0,
+      idleConsecutive: 0,
+    });
+    const mux = multiplexer();
+    mux.closePane.mockResolvedValue(false);
+    new CmuxSessionLifecycle('new', mux, 'http://server', '/repo', undefined, {
+      closeRetryMaxAttempts: 1,
+    });
+    await Promise.resolve();
+    await Promise.resolve();
+    expect(mux.closePane).toHaveBeenCalledTimes(1);
+    expect(store.get('orphan')).toMatchObject({
+      owner: 'new',
+      lifecycle: 'orphaned',
+      paneId: 'orphan-pane',
+    });
+  });
+});

+ 569 - 0
src/multiplexer/cmux/session-lifecycle.ts

@@ -0,0 +1,569 @@
+import { POLL_INTERVAL_BACKGROUND_MS } from '../../config';
+import type { Multiplexer } from '../types';
+import { isServerRunning } from '../types';
+import { CmuxClosePolicy, type CmuxCloseReason } from './close-policy';
+import { type CmuxSessionRecord, CmuxSessionStore } from './session-state';
+
+export interface CmuxSessionEvent {
+  type: string;
+  properties?: {
+    info?: {
+      id?: string;
+      parentID?: string;
+      title?: string;
+      directory?: string;
+      sessionID?: string;
+    };
+    part?: { sessionID?: string };
+    sessionID?: string;
+    status?: { type: string };
+  };
+}
+
+interface BackgroundJobs {
+  deferIfRunning(session: string): boolean;
+  clearDeferredClose(session: string): void;
+}
+
+export interface CmuxSessionLifecycleOptions {
+  now?: () => number;
+  delay?: (milliseconds: number) => Promise<void>;
+  deferredRetryMs?: number;
+  deferredTtlMs?: number;
+  missingGraceMs?: number;
+  closeRetryMs?: number;
+  closeRetryTtlMs?: number;
+  closeRetryMaxAttempts?: number;
+  orphanCooldownMs?: number;
+  shutdownTimeoutMs?: number;
+  isServerRunning?: (url: string) => Promise<boolean>;
+  fetchStatuses?: () => Promise<Record<string, { type: string }>>;
+}
+
+const ACTIVITY_EVENTS = new Set([
+  'message.updated',
+  'message.removed',
+  'message.part.updated',
+  'message.part.delta',
+  'message.part.removed',
+]);
+const MIN_LIFETIME_MS = 10_000;
+const IDLE_CONFIRMATIONS = 3;
+
+export class CmuxSessionLifecycle {
+  private readonly store = new CmuxSessionStore();
+  private readonly policy: CmuxClosePolicy;
+  private readonly now: () => number;
+  private readonly delay: (milliseconds: number) => Promise<void>;
+  private readonly injectedDelay: boolean;
+  private readonly deferredRetryMs: number;
+  private readonly deferredTtlMs: number;
+  private readonly missingGraceMs: number;
+  private readonly closeRetryMs: number;
+  private readonly shutdownTimeoutMs: number;
+  private readonly serverCheck: (url: string) => Promise<boolean>;
+  private readonly fetchStatuses: () => Promise<
+    Record<string, { type: string }>
+  >;
+  private pollTimer?: ReturnType<typeof setInterval>;
+  private polling = false;
+  private cleanupPromise?: Promise<void>;
+  private disposed = false;
+  private spawnGeneration = 0;
+
+  constructor(
+    private readonly owner: string,
+    private readonly multiplexer: Multiplexer,
+    private readonly serverUrl: string,
+    private readonly defaultDirectory: string,
+    private readonly backgroundJobs?: BackgroundJobs,
+    options: CmuxSessionLifecycleOptions = {},
+  ) {
+    this.now = options.now ?? Date.now;
+    this.injectedDelay = Boolean(options.delay);
+    this.delay =
+      options.delay ??
+      ((milliseconds) =>
+        new Promise((resolve) => setTimeout(resolve, milliseconds)));
+    this.deferredRetryMs = options.deferredRetryMs ?? 2_000;
+    this.deferredTtlMs = options.deferredTtlMs ?? 300_000;
+    this.missingGraceMs = options.missingGraceMs ?? 30_000;
+    this.closeRetryMs = options.closeRetryMs ?? 1_000;
+    this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 5_000;
+    this.policy = new CmuxClosePolicy(
+      options.closeRetryTtlMs,
+      options.closeRetryMaxAttempts,
+    );
+    this.serverCheck = options.isServerRunning ?? isServerRunning;
+    this.fetchStatuses = options.fetchStatuses ?? (() => this.loadStatuses());
+    for (const orphan of this.store.claimOrphans(owner, defaultDirectory)) {
+      orphan.closeIntent = undefined;
+      void this.requestClose(orphan, 'cleanup');
+    }
+  }
+
+  async onSessionCreated(event: CmuxSessionEvent): Promise<void> {
+    if (this.disposed) return;
+    if (event.type !== 'session.created') return;
+    const info = event.properties?.info;
+    if (!info?.id || !info.parentID) return;
+    const now = this.now();
+    const record: CmuxSessionRecord = {
+      session: info.id,
+      owner: this.owner,
+      parent: info.parentID,
+      title: info.title ?? 'Subagent',
+      directory: info.directory ?? this.defaultDirectory,
+      spawnState: 'known',
+      lifecycle: 'active',
+      lastActivityAt: now,
+      activityVersion: 0,
+      idleConsecutive: 0,
+    };
+    if (!this.store.claimCreated(record)) return;
+    if (record.paneId && record.lifecycle !== 'active') {
+      record.closeIntent = undefined;
+      await this.requestClose(record, 'cleanup');
+      return;
+    }
+    await this.spawn(record);
+  }
+
+  async onSessionStatus(event: CmuxSessionEvent): Promise<void> {
+    if (this.disposed) return;
+    const session = this.eventSession(event);
+    if (!session) return;
+    const owned = this.store.get(session);
+    if (!owned || owned.owner !== this.owner) return;
+    if (ACTIVITY_EVENTS.has(event.type)) {
+      this.activity(session);
+      return;
+    }
+    const status =
+      event.type === 'session.idle'
+        ? 'idle'
+        : event.type === 'session.status'
+          ? event.properties?.status?.type
+          : undefined;
+    if (!status) return;
+    if (status !== 'idle') {
+      this.activity(session);
+      this.backgroundJobs?.clearDeferredClose(session);
+      const record = this.store.get(session);
+      if (status === 'busy' && record && !record.paneId)
+        await this.spawn(record);
+    }
+    if (owned.paneId) this.startPolling();
+  }
+
+  async onSessionDeleted(event: CmuxSessionEvent): Promise<void> {
+    if (event.type !== 'session.deleted') return;
+    const session = this.eventSession(event);
+    if (!session) return;
+    const record = this.store.get(session);
+    if (!record) return;
+    if (record.owner !== this.owner) return;
+    this.store.markDeleted(session);
+    this.cancelDeferred(record);
+    this.backgroundJobs?.clearDeferredClose(session);
+    if (!record.paneId) {
+      if (!record.spawnPromise) this.store.removeWithoutPane(session);
+      return;
+    }
+    await this.requestClose(record, 'deleted');
+  }
+
+  async closeSessionFromCoordinator(session: string): Promise<void> {
+    if (this.disposed) return;
+    const record = this.store.get(session);
+    if (record?.paneId && record.owner === this.owner)
+      await this.requestClose(record, 'idle');
+  }
+
+  cleanup(): Promise<void> {
+    this.cleanupPromise ??= this.runCleanup();
+    return this.cleanupPromise;
+  }
+
+  /** Runs one status pass; exposed for deterministic lifecycle tests. */
+  pollOnce(): Promise<void> {
+    return this.poll();
+  }
+
+  private async spawn(
+    record: CmuxSessionRecord,
+    deferred = false,
+  ): Promise<void> {
+    if (this.disposed || record.owner !== this.owner) return;
+    if (record.spawnState === 'spawning' || record.paneId) return;
+    const generation = this.spawnGeneration;
+    const token = record.deferredSpawn?.generation;
+    record.spawnState = 'spawning';
+    const operation = this.spawnOperation(record);
+    record.spawnPromise = operation;
+    const result = await operation;
+    if (record.spawnPromise === operation) record.spawnPromise = undefined;
+    const current = this.store.get(record.session);
+    if (this.disposed || generation !== this.spawnGeneration) {
+      const latePane = result.paneId ?? result.orphanPaneId;
+      if (latePane) await this.closeLatePane(record, latePane);
+      else if (current && !current.paneId)
+        this.store.removeWithoutPane(record.session);
+      return;
+    }
+    if (!current) {
+      if (result.success && result.paneId)
+        await this.adoptAndClose(record, result.paneId);
+      if (result.orphanPaneId)
+        await this.adoptAndClose(record, result.orphanPaneId);
+      return;
+    }
+    if (
+      deferred &&
+      token !== undefined &&
+      current.deferredSpawn?.generation !== token
+    ) {
+      const stalePane = result.paneId ?? result.orphanPaneId;
+      if (stalePane) await this.adoptAndClose(current, stalePane);
+      return;
+    }
+    const paneId = result.paneId ?? result.orphanPaneId;
+    if (paneId) {
+      this.store.markAttached(record.session, paneId, this.now());
+      if (result.orphanPaneId) this.store.markOrphaned(record.session);
+      if (current.lifecycle !== 'active' || result.orphanPaneId) {
+        await this.requestClose(
+          current,
+          current.lifecycle === 'active' ? 'cleanup' : 'deleted',
+        );
+      } else this.startPolling();
+      return;
+    }
+    current.spawnState = 'failed';
+    if (current.lifecycle !== 'active') {
+      this.store.removeWithoutPane(current.session);
+    } else if (
+      result.error === 'unavailable' ||
+      result.error === 'not_found' ||
+      result.error === 'invalid_state'
+    ) {
+      this.deferSpawn(current);
+    }
+  }
+
+  private async spawnOperation(record: CmuxSessionRecord) {
+    if (!(await this.serverCheck(this.serverUrl))) {
+      return { success: false, error: 'unavailable' as const };
+    }
+    try {
+      return await this.multiplexer.spawnPane(
+        record.session,
+        record.title,
+        this.serverUrl,
+        record.directory,
+      );
+    } catch {
+      return { success: false, error: 'hard' as const };
+    }
+  }
+
+  private deferSpawn(record: CmuxSessionRecord): void {
+    if (this.disposed || record.owner !== this.owner) return;
+    const existing = record.deferredSpawn;
+    const deferred = existing ?? {
+      deadline: this.now() + this.deferredTtlMs,
+      generation: 0,
+    };
+    deferred.generation += 1;
+    deferred.timer?.cancel();
+    record.deferredSpawn = deferred;
+    if (this.now() >= deferred.deadline) {
+      this.cancelDeferred(record);
+      return;
+    }
+    deferred.timer = this.timer(async () => {
+      deferred.timer = undefined;
+      if (
+        this.disposed ||
+        this.store.get(record.session) !== record ||
+        record.lifecycle !== 'active' ||
+        record.owner !== this.owner
+      )
+        return;
+      await this.spawn(record, true);
+    }, this.deferredRetryMs);
+  }
+
+  private cancelDeferred(record: CmuxSessionRecord): void {
+    record.deferredSpawn?.timer?.cancel();
+    record.deferredSpawn = undefined;
+  }
+
+  private activity(session: string): void {
+    const record = this.store.get(session);
+    if (!record || record.owner !== this.owner || this.disposed) return;
+    this.store.markActivity(session, this.now());
+    const next = this.policy.activity(record.closeIntent);
+    if (!next && record.closeIntent) record.closeTimer?.cancel();
+    record.closeIntent = next;
+    if (!next) record.closeTimer = undefined;
+  }
+
+  private async requestClose(
+    record: CmuxSessionRecord,
+    reason: CmuxCloseReason,
+  ): Promise<void> {
+    if (!record.paneId || record.owner !== this.owner) return;
+    if (
+      reason === 'idle' &&
+      !(this.backgroundJobs?.deferIfRunning(record.session) ?? true)
+    )
+      return;
+    const previous = record.closeIntent;
+    record.closeIntent = this.policy.request(
+      reason,
+      record.activityVersion,
+      this.now(),
+      previous,
+    );
+    if (previous !== record.closeIntent) record.closeTimer?.cancel();
+    await this.attemptClose(record);
+  }
+
+  private async attemptClose(record: CmuxSessionRecord): Promise<void> {
+    const intent = record.closeIntent;
+    if (
+      !intent ||
+      !record.paneId ||
+      record.owner !== this.owner ||
+      this.store.get(record.session) !== record
+    )
+      return;
+    if (
+      intent.reason === 'idle' &&
+      intent.expectedActivityVersion !== record.activityVersion
+    ) {
+      record.closeIntent = this.policy.activity(intent);
+      return;
+    }
+    let closed = false;
+    try {
+      closed = await this.multiplexer.closePane(record.paneId);
+    } catch {}
+    const intentStillCurrent = record.closeIntent === intent;
+    const idleStillCurrent =
+      intent.reason !== 'idle' ||
+      intent.expectedActivityVersion === record.activityVersion;
+    if (closed) {
+      record.closeTimer?.cancel();
+      record.closeTimer = undefined;
+      if (record.lifecycle !== 'active') {
+        this.store.removeAfterConfirmedClose(record.session);
+      } else {
+        record.paneId = undefined;
+        record.spawnState = 'known';
+        if (intentStillCurrent) record.closeIntent = this.policy.complete();
+        if (
+          intent.reason === 'idle' &&
+          record.owner === this.owner &&
+          !this.disposed &&
+          record.activityVersion !== intent.expectedActivityVersion
+        ) {
+          await this.spawn(record);
+        }
+      }
+      this.updatePolling();
+      return;
+    }
+    if (!intentStillCurrent || !idleStillCurrent) return;
+    record.closeIntent = this.policy.failed(intent, this.now());
+    if (record.closeIntent.phase === 'cooldown') {
+      this.store.markOrphaned(record.session);
+      record.closeTimer = undefined;
+      return;
+    }
+    record.closeTimer?.cancel();
+    record.closeTimer = this.timer(
+      () => this.attemptClose(record),
+      this.closeRetryMs,
+    );
+  }
+
+  private async poll(): Promise<void> {
+    if (this.polling || this.disposed) return;
+    this.polling = true;
+    try {
+      const statuses = await this.fetchStatuses();
+      for (const record of this.store.ownedBy(this.owner)) {
+        if (!record.paneId || record.lifecycle !== 'active') continue;
+        const status = statuses[record.session];
+        if (!status) {
+          record.idleConsecutive = 0;
+          record.statusMissingSince ??= this.now();
+          if (this.now() - record.statusMissingSince >= this.missingGraceMs)
+            await this.requestClose(record, 'idle');
+          continue;
+        }
+        record.statusMissingSince = undefined;
+        if (status.type !== 'idle') {
+          this.activity(record.session);
+          continue;
+        }
+        if (
+          this.now() - (record.attachedAt ?? this.now()) < MIN_LIFETIME_MS ||
+          this.now() - record.lastActivityAt < MIN_LIFETIME_MS
+        ) {
+          record.idleConsecutive = 0;
+          continue;
+        }
+        record.idleConsecutive += 1;
+        if (record.idleConsecutive < IDLE_CONFIRMATIONS) continue;
+        const version = record.activityVersion;
+        const final = await this.fetchStatuses();
+        if (
+          final[record.session]?.type === 'idle' &&
+          version === record.activityVersion
+        )
+          await this.requestClose(record, 'idle');
+        else this.activity(record.session);
+      }
+    } catch {
+      // A transient status endpoint failure must not reject the interval task.
+    } finally {
+      this.polling = false;
+    }
+  }
+
+  private startPolling(): void {
+    if (this.pollTimer || this.disposed) return;
+    this.pollTimer = setInterval(
+      () => void this.poll().catch(() => undefined),
+      POLL_INTERVAL_BACKGROUND_MS,
+    );
+    this.pollTimer.unref?.();
+  }
+  private updatePolling(): void {
+    if (this.store.ownedBy(this.owner).some((record) => record.paneId))
+      this.startPolling();
+    else if (this.pollTimer) {
+      clearInterval(this.pollTimer);
+      this.pollTimer = undefined;
+    }
+  }
+
+  private async runCleanup(): Promise<void> {
+    this.disposed = true;
+    this.spawnGeneration += 1;
+    if (this.pollTimer) clearInterval(this.pollTimer);
+    this.pollTimer = undefined;
+    const records = this.store.ownedBy(this.owner);
+    for (const record of records) this.cancelDeferred(record);
+    const pending = records.flatMap((record) =>
+      record.spawnPromise ? [record.spawnPromise] : [],
+    );
+    if (pending.length) {
+      await Promise.race([
+        Promise.allSettled(pending),
+        this.delay(this.shutdownTimeoutMs),
+      ]);
+    }
+    for (const record of this.store.ownedBy(this.owner)) {
+      if (!record.paneId) {
+        if (!record.spawnPromise) this.store.removeWithoutPane(record.session);
+        continue;
+      }
+      record.closeTimer?.cancel();
+      record.closeTimer = undefined;
+      record.closeIntent = this.policy.request(
+        'cleanup',
+        record.activityVersion,
+        this.now(),
+      );
+      while (record.closeIntent?.phase === 'pending') {
+        await this.attemptCloseWithoutTimer(record);
+        if (record.closeIntent?.phase === 'pending')
+          await this.delay(this.closeRetryMs);
+      }
+      if (record.closeIntent) this.store.markOrphaned(record.session);
+    }
+  }
+
+  private async attemptCloseWithoutTimer(
+    record: CmuxSessionRecord,
+  ): Promise<void> {
+    const intent = record.closeIntent;
+    if (!intent || !record.paneId) return;
+    let closed = false;
+    try {
+      closed = await this.multiplexer.closePane(record.paneId);
+    } catch {}
+    if (closed) {
+      record.closeIntent = undefined;
+      this.store.removeAfterConfirmedClose(record.session);
+    } else record.closeIntent = this.policy.failed(intent, this.now());
+  }
+
+  private async adoptAndClose(
+    record: CmuxSessionRecord,
+    paneId: string,
+  ): Promise<void> {
+    if (!this.store.get(record.session)) this.store.claimCreated(record);
+    this.store.markAttached(record.session, paneId, this.now());
+    this.store.markOrphaned(record.session);
+    await this.requestClose(record, 'cleanup');
+  }
+
+  private async closeLatePane(
+    source: CmuxSessionRecord,
+    paneId: string,
+  ): Promise<void> {
+    const existing = this.store.get(source.session);
+    const record = existing ?? source;
+    if (!existing) this.store.claimCreated(record);
+    record.owner = this.owner;
+    record.paneId = paneId;
+    record.spawnState = 'attached';
+    record.lifecycle = 'orphaned';
+    record.closeIntent = this.policy.request(
+      'cleanup',
+      record.activityVersion,
+      this.now(),
+    );
+    while (record.closeIntent?.phase === 'pending') {
+      await this.attemptCloseWithoutTimer(record);
+      if (record.closeIntent?.phase === 'pending')
+        await this.delay(this.closeRetryMs);
+    }
+  }
+
+  private eventSession(event: CmuxSessionEvent): string | undefined {
+    return (
+      event.properties?.sessionID ??
+      event.properties?.info?.sessionID ??
+      event.properties?.part?.sessionID ??
+      event.properties?.info?.id
+    );
+  }
+
+  private timer(callback: () => void | Promise<void>, milliseconds: number) {
+    let cancelled = false;
+    if (this.injectedDelay) {
+      void this.delay(milliseconds).then(() => {
+        if (!cancelled) void callback();
+      });
+      return { cancel: () => (cancelled = true) };
+    }
+    const timer = setTimeout(() => void callback(), milliseconds);
+    timer.unref?.();
+    return { cancel: () => clearTimeout(timer) };
+  }
+
+  private async loadStatuses(): Promise<Record<string, { type: string }>> {
+    const response = await fetch(new URL('/session/status', this.serverUrl), {
+      signal: AbortSignal.timeout(2_000),
+    });
+    if (!response.ok)
+      throw new Error(`session status failed: ${response.status}`);
+    return (await response.json()) as Record<string, { type: string }>;
+  }
+}

+ 123 - 0
src/multiplexer/cmux/session-state.ts

@@ -0,0 +1,123 @@
+import type { PaneResult } from '../types';
+import type { CmuxCloseIntent } from './close-policy';
+
+export type CmuxSpawnState = 'known' | 'spawning' | 'attached' | 'failed';
+export type CmuxLifecycleState = 'active' | 'deleted' | 'orphaned';
+
+export interface CmuxDeferredSpawn {
+  deadline: number;
+  generation: number;
+  timer?: { cancel(): void };
+}
+
+export interface CmuxSessionRecord {
+  session: string;
+  owner: string;
+  parent: string;
+  title: string;
+  directory: string;
+  paneId?: string;
+  spawnState: CmuxSpawnState;
+  lifecycle: CmuxLifecycleState;
+  attachedAt?: number;
+  lastActivityAt: number;
+  activityVersion: number;
+  idleConsecutive: number;
+  statusMissingSince?: number;
+  deferredSpawn?: CmuxDeferredSpawn;
+  closeIntent?: CmuxCloseIntent;
+  closeTimer?: { cancel(): void };
+  spawnPromise?: Promise<PaneResult>;
+}
+
+const STORE_KEY = Symbol.for('oh-my-opencode-slim.cmux-session-store');
+
+function records(): Map<string, CmuxSessionRecord> {
+  const globalStore = globalThis as typeof globalThis & {
+    [STORE_KEY]?: Map<string, CmuxSessionRecord>;
+  };
+  globalStore[STORE_KEY] ??= new Map();
+  return globalStore[STORE_KEY];
+}
+
+export class CmuxSessionStore {
+  claimCreated(record: CmuxSessionRecord): boolean {
+    const existing = records().get(record.session);
+    if (existing) {
+      if (
+        existing.directory !== record.directory ||
+        (existing.lifecycle !== 'orphaned' && existing.lifecycle !== 'deleted')
+      )
+        return false;
+      existing.closeTimer?.cancel();
+      Object.assign(record, existing, {
+        owner: record.owner,
+        closeTimer: undefined,
+      });
+    }
+    records().set(record.session, record);
+    return true;
+  }
+  get(session: string): CmuxSessionRecord | undefined {
+    return records().get(session);
+  }
+  ownedBy(owner: string): CmuxSessionRecord[] {
+    return [...records().values()].filter((record) => record.owner === owner);
+  }
+  claimOrphans(owner: string, directory: string): CmuxSessionRecord[] {
+    const claimed = [...records().values()].filter(
+      (record) =>
+        record.directory === directory &&
+        Boolean(record.paneId) &&
+        (record.lifecycle === 'orphaned' || record.lifecycle === 'deleted'),
+    );
+    for (const record of claimed) {
+      record.closeTimer?.cancel();
+      record.closeTimer = undefined;
+      record.owner = owner;
+    }
+    return claimed;
+  }
+  markAttached(session: string, paneId: string, now: number): void {
+    const record = records().get(session);
+    if (!record) return;
+    Object.assign(record, {
+      paneId,
+      attachedAt: now,
+      lastActivityAt: now,
+      spawnState: 'attached',
+      deferredSpawn: undefined,
+    });
+  }
+  markActivity(session: string, now: number): void {
+    const record = records().get(session);
+    if (!record) return;
+    record.lastActivityAt = now;
+    record.activityVersion += 1;
+    record.idleConsecutive = 0;
+    record.statusMissingSince = undefined;
+  }
+  markDeleted(session: string): void {
+    const record = records().get(session);
+    if (record) record.lifecycle = 'deleted';
+  }
+  markOrphaned(session: string): void {
+    const record = records().get(session);
+    if (record) record.lifecycle = 'orphaned';
+  }
+  removeAfterConfirmedClose(session: string): boolean {
+    const record = records().get(session);
+    return record?.paneId ? records().delete(session) : false;
+  }
+  removeWithoutPane(session: string): boolean {
+    const record = records().get(session);
+    return record && !record.paneId ? records().delete(session) : false;
+  }
+  resetForTests(): void {
+    for (const record of records().values()) {
+      record.deferredSpawn?.timer?.cancel();
+      record.closeTimer?.cancel();
+    }
+    records().clear();
+  }
+}

+ 23 - 7
src/multiplexer/codemap.md

@@ -2,7 +2,8 @@
 
 ## Responsibility
 
-Provides a unified abstraction layer for terminal multiplexers (tmux and zellij) to spawn, manage, and close panes for child OpenCode agent sessions. This enables a "multiplexer-assisted" workflow where each child session runs in its own terminal pane, providing better isolation and resource management compared to traditional background processes.
+Provides a unified abstraction for tmux, Zellij, Herdr, and cmux to spawn,
+manage, and close panes for child OpenCode agent sessions.
 
 ## Design
 
@@ -13,15 +14,25 @@ Provides a unified abstraction layer for terminal multiplexers (tmux and zellij)
   - `TmuxMultiplexer`: tmux-specific implementation using `tmux` CLI commands
   - `ZellijMultiplexer`: zellij-specific implementation using zellij plugin API
   - `HerdrMultiplexer`: herdr-specific implementation using `herdr` CLI commands
+  - `CmuxMultiplexer`: cmux UUID surface implementation using the cmux CLI
 - **Shared Utilities** (`shared.ts`): `quoteShellArg`, `buildOpencodeAttachCommand`, and `findBinary` — extracted from the three adapters to eliminate copy-paste duplication.
 - **Session Manager** (`session-manager.ts`): Tracks child session lifecycle and coordinates pane operations via event-driven architecture.
+- **cmux lifecycle** (`cmux/session-lifecycle.ts`): Owns readiness, deferred
+  spawning, stable-idle polling, activity generations, reliable close retries,
+  tracked orphan cooldown, cleanup, timers, and ownership. The generic manager
+  delegates its five public lifecycle entries for cmux.
+- **cmux state/policy** (`cmux/session-state.ts`, `cmux/close-policy.ts`):
+  Shared single-record storage and pure close-intent transitions. Pane records
+  are removed only after confirmed close. The store uses a global symbol so a
+  same-directory lifecycle can take over tracked orphans after hot reload;
+  retry budgets remain finite per lifecycle instance.
 - **Factory** (`factory.ts`): Creates appropriate multiplexer instance based on configuration and environment detection.
 
 ### Key Interfaces
 
 ```typescript
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij';
+  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux';
   isAvailable(): Promise<boolean>;
   isInsideSession(): boolean;
   spawnPane(sessionId: string, description: string, serverUrl: string, directory: string): Promise<PaneResult>;
@@ -68,9 +79,8 @@ The session manager reacts to OpenCode session events:
    ├─ Records session in sessions map with pane metadata
    └─ Starts polling loop if not already running
 
-3. Multiplexer implementation spawns pane:
-   ├─ Tmux: Uses 'tmux new-window' or 'tmux split-window'
-   └─ Zellij: Uses zellij plugin API to create new pane
+3. The selected tmux, Zellij, Herdr, or cmux implementation creates the pane
+   or surface. cmux delegates lifecycle reliability to `CmuxSessionLifecycle`.
 ```
 
 ### Session Completion Flow
@@ -119,7 +129,7 @@ The session manager reacts to OpenCode session events:
 
 ```typescript
 interface MultiplexerConfig {
-  type: 'tmux' | 'zellij' | 'auto' | 'none';
+  type: 'tmux' | 'zellij' | 'herdr' | 'cmux' | 'auto' | 'none';
   layout: MultiplexerLayout; // 'tiled' | 'main-horizontal' | 'main-vertical' | 'grid'
   main_pane_size?: number; // Percentage for main pane (0-100)
   zellij_pane_mode?: string; // Zellij-specific pane mode
@@ -128,7 +138,9 @@ interface MultiplexerConfig {
 
 ### Environment Detection
 
-- **Auto Mode**: Detects multiplexer type from environment variables (`TMUX` or `ZELLIJ`)
+- **Auto Mode**: Detects tmux (`TMUX`), Zellij (`ZELLIJ`), Herdr
+  (`HERDR_ENV`/`HERDR_PANE_ID`), or cmux (complete `CMUX_SOCKET_PATH`,
+  `CMUX_WORKSPACE_ID`, and `CMUX_SURFACE_ID` identity).
 - **Availability Check**: Validates multiplexer binary is available before use
 
 ## Implementation Details
@@ -172,3 +184,7 @@ interface MultiplexerConfig {
 | `tmux/index.ts` | tmux-specific implementation |
 | `zellij/index.ts` | zellij-specific implementation |
 | `herdr/index.ts` | herdr-specific implementation |
+| `cmux/index.ts` | cmux adapter and encoded surface handles |
+| `cmux/session-lifecycle.ts` | cmux event, polling, spawn, close, orphan, and cleanup ownership |
+| `cmux/session-state.ts` | process-global cmux session registry |
+| `cmux/close-policy.ts` | pure cmux close-intent transitions and retry budgets |

+ 43 - 1
src/multiplexer/factory.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 
 async function importFreshFactory(suffix: string) {
   return import(`./factory?test=${suffix}-${Date.now()}-${Math.random()}`);
@@ -9,12 +9,24 @@ describe('multiplexer factory', () => {
   const originalTmuxPane = process.env.TMUX_PANE;
   const originalHerdrEnv = process.env.HERDR_ENV;
   const originalHerdrPaneId = process.env.HERDR_PANE_ID;
+  const originalCmuxSocket = process.env.CMUX_SOCKET_PATH;
+  const originalCmuxWorkspace = process.env.CMUX_WORKSPACE_ID;
+  const originalCmuxSurface = process.env.CMUX_SURFACE_ID;
+
+  beforeEach(() => {
+    delete process.env.CMUX_SOCKET_PATH;
+    delete process.env.CMUX_WORKSPACE_ID;
+    delete process.env.CMUX_SURFACE_ID;
+  });
 
   afterEach(() => {
     process.env.TMUX = originalTmux;
     process.env.TMUX_PANE = originalTmuxPane;
     process.env.HERDR_ENV = originalHerdrEnv;
     process.env.HERDR_PANE_ID = originalHerdrPaneId;
+    process.env.CMUX_SOCKET_PATH = originalCmuxSocket;
+    process.env.CMUX_WORKSPACE_ID = originalCmuxWorkspace;
+    process.env.CMUX_SURFACE_ID = originalCmuxSurface;
   });
 
   test('returns a fresh tmux instance per call', async () => {
@@ -131,4 +143,34 @@ describe('multiplexer factory', () => {
     expect(multiplexer).not.toBeNull();
     expect(multiplexer?.type).toBe('herdr');
   });
+
+  test('returns a cmux instance when explicitly configured', async () => {
+    const { getMultiplexer } = await importFreshFactory('cmux-explicit');
+    const multiplexer = getMultiplexer({
+      type: 'cmux',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+    expect(multiplexer?.type).toBe('cmux');
+  });
+
+  test('auto-detects cmux only with the complete cmux identity environment', async () => {
+    delete process.env.TMUX;
+    delete process.env.ZELLIJ;
+    delete process.env.HERDR_ENV;
+    delete process.env.HERDR_PANE_ID;
+    process.env.CMUX_SOCKET_PATH = '/tmp/cmux.sock';
+    process.env.CMUX_WORKSPACE_ID = 'workspace-1';
+    process.env.CMUX_SURFACE_ID = 'surface-1';
+    const { getMultiplexer } = await importFreshFactory('auto-cmux');
+    expect(
+      getMultiplexer({
+        type: 'auto',
+        layout: 'main-vertical',
+        main_pane_size: 60,
+        zellij_pane_mode: 'agent-tab',
+      })?.type,
+    ).toBe('cmux');
+  });
 });

+ 13 - 1
src/multiplexer/factory.ts

@@ -4,6 +4,7 @@
 
 import type { MultiplexerConfig, MultiplexerType } from '../config/schema';
 import { log } from '../utils/logger';
+import { CmuxMultiplexer } from './cmux';
 import { HerdrMultiplexer } from './herdr';
 import { TmuxMultiplexer } from './tmux';
 import type { Multiplexer } from './types';
@@ -44,10 +45,21 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       multiplexer = new HerdrMultiplexer(config.layout, config.main_pane_size);
       actualType = 'herdr';
       break;
+    case 'cmux':
+      multiplexer = new CmuxMultiplexer();
+      actualType = 'cmux';
+      break;
     case 'auto': {
       // Auto-detect based on environment variables only
       // Note: Does NOT fall back to binary availability checks
-      if (process.env.TMUX) {
+      if (
+        process.env.CMUX_SOCKET_PATH &&
+        process.env.CMUX_WORKSPACE_ID &&
+        process.env.CMUX_SURFACE_ID
+      ) {
+        multiplexer = new CmuxMultiplexer();
+        actualType = 'cmux';
+      } else if (process.env.TMUX) {
         multiplexer = new TmuxMultiplexer(config.layout, config.main_pane_size);
         actualType = 'tmux';
       } else if (process.env.ZELLIJ) {

+ 2 - 0
src/multiplexer/index.ts

@@ -2,6 +2,8 @@
  * Multiplexer module exports
  */
 
+export type { CmuxClient, CommandRunner } from './cmux';
+export { CliCmuxClient, CmuxMultiplexer } from './cmux';
 export {
   getMultiplexer,
   startAvailabilityCheck,

+ 476 - 6
src/multiplexer/session-manager.test.ts

@@ -17,8 +17,11 @@ const mockFetch = mock(
 );
 
 // Define the mock multiplexer
+let mockMultiplexerType: 'tmux' | 'cmux' = 'tmux';
 const mockMultiplexer = {
-  type: 'tmux' as const,
+  get type() {
+    return mockMultiplexerType;
+  },
   isAvailable: mock(async () => true),
   isInsideSession: mock(() => true),
   spawnPane: mock(async () => ({
@@ -28,11 +31,12 @@ const mockMultiplexer = {
   closePane: mock(async () => true),
   applyLayout: mock(async () => {}),
 };
+const mockIsServerRunning = mock(async () => true);
 
 // Mock the multiplexer module
 mock.module('../multiplexer', () => ({
   getMultiplexer: () => mockMultiplexer,
-  isServerRunning: mock(async () => true),
+  isServerRunning: mockIsServerRunning,
   startAvailabilityCheck: () => {},
 }));
 
@@ -79,6 +83,10 @@ function createDeferred<T>() {
   return { promise, resolve, reject };
 }
 
+async function flushPromises(count = 8): Promise<void> {
+  for (let index = 0; index < count; index++) await Promise.resolve();
+}
+
 describe('MultiplexerSessionManager', () => {
   beforeEach(() => {
     resetMultiplexerSessionManagerState();
@@ -94,6 +102,9 @@ describe('MultiplexerSessionManager', () => {
     mockMultiplexer.closePane.mockResolvedValue(true);
     mockMultiplexer.isInsideSession.mockReset();
     mockMultiplexer.isInsideSession.mockReturnValue(true);
+    mockMultiplexerType = 'tmux';
+    mockIsServerRunning.mockReset();
+    mockIsServerRunning.mockResolvedValue(true);
   });
 
   afterEach(() => {
@@ -1396,7 +1407,467 @@ describe('MultiplexerSessionManager', () => {
     });
   });
 
+  describe('cmux lifecycle', () => {
+    const cmuxConfig = { ...defaultMultiplexerConfig, type: 'cmux' as const };
+
+    test('requires lifetime, three idle polls, and a final idle recheck', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 0;
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { now: () => now },
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-idle', parentID: 'parent' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.idle',
+        properties: { sessionID: 'cmux-idle' },
+      });
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      setMockSessionStatuses({ 'cmux-idle': { type: 'idle' } });
+      await (manager as any).pollSessions();
+      now = 10_000;
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      await (manager as any).pollSessions();
+      expect(mockFetch).toHaveBeenCalledTimes(5);
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('%mock-pane');
+    });
+
+    test('activity resets idle stability and missing status is a grace', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 0;
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { now: () => now },
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-active', parentID: 'parent' } },
+      });
+      now = 10_000;
+      setMockSessionStatuses({ 'cmux-active': { type: 'idle' } });
+      await (manager as any).pollSessions();
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'cmux-active',
+          status: { type: 'message' },
+        },
+      });
+      now = 20_000;
+      setMockSessionStatuses({});
+      await (manager as any).pollSessions();
+      setMockSessionStatuses({ 'cmux-active': { type: 'idle' } });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+    });
+
+    test('delete and cleanup cancel deferred retries', async () => {
+      for (const action of ['delete', 'cleanup'] as const) {
+        resetMultiplexerSessionManagerState();
+        mockMultiplexer.spawnPane.mockReset();
+        mockMultiplexer.spawnPane.mockResolvedValue({
+          success: false,
+          error: 'invalid_state',
+        });
+        mockMultiplexerType = 'cmux';
+        const retry = createDeferred<void>();
+        const manager = new MultiplexerSessionManager(
+          createMockContext(),
+          cmuxConfig,
+          undefined,
+          { delay: () => retry.promise },
+        );
+        await manager.onSessionCreated({
+          type: 'session.created',
+          properties: { info: { id: action, parentID: 'parent' } },
+        });
+        if (action === 'delete') {
+          await manager.onSessionDeleted({
+            type: 'session.deleted',
+            properties: { sessionID: action },
+          });
+        } else {
+          await manager.cleanup();
+        }
+        retry.resolve();
+        await Promise.resolve();
+        expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      }
+    });
+
+    test('hard spawn failures are not deferred', async () => {
+      mockMultiplexerType = 'cmux';
+      const delay = mock(async () => {});
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: false,
+        error: 'hard',
+      });
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { delay },
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'hard', parentID: 'parent' } },
+      });
+      expect(delay).not.toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
+
+    test('server-down deferred retry recovers and expires at original TTL', async () => {
+      for (const recovers of [true, false]) {
+        resetMultiplexerSessionManagerState();
+        mockMultiplexer.spawnPane.mockReset();
+        mockMultiplexer.spawnPane
+          .mockResolvedValueOnce({ success: false, error: 'unavailable' })
+          .mockResolvedValue({ success: true, paneId: `server-${recovers}` });
+        mockIsServerRunning.mockReset();
+        mockIsServerRunning.mockResolvedValue(true);
+        mockMultiplexerType = 'cmux';
+        let now = 0;
+        const retries: Array<ReturnType<typeof createDeferred<void>>> = [];
+        const manager = new MultiplexerSessionManager(
+          createMockContext(),
+          cmuxConfig,
+          undefined,
+          {
+            now: () => now,
+            deferredTtlMs: 10,
+            delay: () => {
+              const retry = createDeferred<void>();
+              retries.push(retry);
+              return retry.promise;
+            },
+          },
+        );
+        await manager.onSessionCreated({
+          type: 'session.created',
+          properties: { info: { id: `down-${recovers}`, parentID: 'p' } },
+        });
+        mockIsServerRunning.mockResolvedValue(false);
+        now = 5;
+        retries[0]?.resolve();
+        await flushPromises();
+        if (recovers) mockIsServerRunning.mockResolvedValue(true);
+        else now = 10;
+        retries[1]?.resolve();
+        await flushPromises();
+        expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(
+          recovers ? 2 : 1,
+        );
+      }
+    });
+
+    test('message events and close-list races reset cmux idle', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 0;
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { now: () => now },
+      );
+      for (const id of ['message-a', 'message-b']) {
+        await manager.onSessionCreated({
+          type: 'session.created',
+          properties: { info: { id, parentID: 'parent' } },
+        });
+      }
+      now = 10_000;
+      setMockSessionStatuses({
+        'message-a': { type: 'idle' },
+        'message-b': { type: 'idle' },
+      });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      const originalClose = mockMultiplexer.closePane;
+      originalClose.mockImplementationOnce(async () => {
+        await manager.onSessionStatus({
+          type: 'message.part.delta',
+          properties: { sessionID: 'message-b' },
+        });
+        return true;
+      });
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+      await manager.onSessionStatus({
+        type: 'message.updated',
+        properties: { info: { sessionID: 'message-b' } },
+      });
+      await manager.onSessionStatus({
+        type: 'message.removed',
+        properties: { sessionID: 'message-b' },
+      });
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+    });
+
+    test('deleted stale spawn and cleanup retry close failures', async () => {
+      mockMultiplexerType = 'cmux';
+      const spawn = createDeferred<{ success: true; paneId: string }>();
+      const retries: Array<ReturnType<typeof createDeferred<void>>> = [];
+      mockMultiplexer.spawnPane.mockImplementationOnce(() => spawn.promise);
+      mockMultiplexer.closePane
+        .mockResolvedValueOnce(false)
+        .mockRejectedValueOnce(new Error('socket'))
+        .mockResolvedValueOnce(true);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        {
+          delay: () => {
+            const retry = createDeferred<void>();
+            retries.push(retry);
+            return retry.promise;
+          },
+        },
+      );
+      const creating = manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'stale-cmux', parentID: 'parent' } },
+      });
+      await flushPromises();
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'stale-cmux' },
+      });
+      spawn.resolve({ success: true, paneId: 'stale-pane' });
+      await creating;
+      retries[0]?.resolve();
+      await flushPromises();
+      retries[1]?.resolve();
+      await flushPromises();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(3);
+    });
+
+    test('retries false and thrown closes until success', async () => {
+      mockMultiplexerType = 'cmux';
+      const retries: Array<ReturnType<typeof createDeferred<void>>> = [];
+      mockMultiplexer.closePane
+        .mockResolvedValueOnce(false)
+        .mockRejectedValueOnce(new Error('socket'))
+        .mockResolvedValueOnce(true);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        {
+          delay: () => {
+            const retry = createDeferred<void>();
+            retries.push(retry);
+            return retry.promise;
+          },
+          closeRetryMaxAttempts: 4,
+        },
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'close-retry', parentID: 'parent' } },
+      });
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'close-retry' },
+      });
+      retries[0]?.resolve();
+      await flushPromises();
+      retries[1]?.resolve();
+      await flushPromises();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(3);
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'close-retry' },
+      });
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(3);
+    });
+
+    test('activity during final recheck cancels close', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 10_000;
+      const finalFetch = createDeferred<Response>();
+      mockFetch
+        .mockResolvedValueOnce(Response.json({ race: { type: 'idle' } }))
+        .mockResolvedValueOnce(Response.json({ race: { type: 'idle' } }))
+        .mockResolvedValueOnce(Response.json({ race: { type: 'idle' } }))
+        .mockImplementationOnce(() => finalFetch.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { now: () => now },
+      );
+      now = 0;
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'race', parentID: 'parent' } },
+      });
+      now = 10_000;
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      const finalPoll = (manager as any).pollSessions();
+      await flushPromises();
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: { sessionID: 'race', status: { type: 'message' } },
+      });
+      finalFetch.resolve(Response.json({ race: { type: 'idle' } }));
+      await finalPoll;
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('poll guard prevents overlapping status fetches', async () => {
+      mockMultiplexerType = 'cmux';
+      const slowFetch = createDeferred<Response>();
+      mockFetch.mockImplementationOnce(() => slowFetch.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'slow', parentID: 'parent' } },
+      });
+      const first = (manager as any).pollSessions();
+      const second = (manager as any).pollSessions();
+      expect(mockFetch).toHaveBeenCalledTimes(1);
+      slowFetch.resolve(Response.json({ slow: { type: 'idle' } }));
+      await Promise.all([first, second]);
+    });
+
+    test('missing resets idle streak and closes after grace expires', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 0;
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        undefined,
+        { now: () => now, missingGraceMs: 30 },
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'missing', parentID: 'parent' } },
+      });
+      now = 10_000;
+      setMockSessionStatuses({ missing: { type: 'idle' } });
+      await (manager as any).pollSessions();
+      setMockSessionStatuses({});
+      await (manager as any).pollSessions();
+      now += 29;
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      setMockSessionStatuses({ missing: { type: 'idle' } });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      setMockSessionStatuses({});
+      await (manager as any).pollSessions();
+      now += 30;
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+    });
+
+    test('background job policy still gates stable cmux idle close', async () => {
+      mockMultiplexerType = 'cmux';
+      let now = 0;
+      const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
+      board.registerLaunch({
+        taskID: 'cmux-background',
+        parentSessionID: 'parent',
+        agent: 'explorer',
+      });
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        coordinator,
+        { now: () => now },
+      );
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
+      });
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'cmux-background', parentID: 'parent' },
+        },
+      });
+      now = 10_000;
+      setMockSessionStatuses({ 'cmux-background': { type: 'idle' } });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+      board.updateStatus({ taskID: 'cmux-background', state: 'completed' });
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+    });
+
+    test('session.deleted closes immediately', async () => {
+      mockMultiplexerType = 'cmux';
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-deleted', parentID: 'parent' } },
+      });
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'cmux-deleted' },
+      });
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('%mock-pane');
+    });
+  });
+
   describe('cleanup', () => {
+    test('instance disposal cleanup is a no-op for non-cmux multiplexers', async () => {
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'tmux-live', parentID: 'parent' } },
+      });
+      mockMultiplexer.closePane.mockClear();
+      await manager.cleanupOnInstanceDisposed();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('cmux cleanup has a shutdown deadline for a hanging spawn', async () => {
+      mockMultiplexerType = 'cmux';
+      const spawn = createDeferred<{ success: true; paneId: string }>();
+      mockMultiplexer.spawnPane.mockImplementationOnce(() => spawn.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        { ...defaultMultiplexerConfig, type: 'cmux' },
+        undefined,
+        { shutdownTimeoutMs: 1 },
+      );
+      void manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'hanging-cleanup', parentID: 'parent' } },
+      });
+      await flushPromises();
+      await manager.cleanup();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
     test('closes all tracked panes concurrently', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane
@@ -1448,13 +1919,12 @@ describe('MultiplexerSessionManager', () => {
 
       await Promise.resolve();
 
-      await manager.cleanup();
+      const cleanupPromise = manager.cleanup();
+      deferred.resolve({ success: true, paneId: 'p-cleanup' });
+      await Promise.all([createPromise, cleanupPromise]);
 
       await manager.onSessionCreated(event);
 
-      deferred.resolve({ success: true, paneId: 'p-cleanup' });
-      await createPromise;
-
       expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(2);
     });
   });

+ 33 - 0
src/multiplexer/session-manager.ts

@@ -9,6 +9,11 @@ import {
 import type { BackgroundJobState } from '../utils/background-job-board';
 import type { BackgroundJobStore } from '../utils/background-job-store';
 import { log } from '../utils/logger';
+import {
+  CmuxSessionLifecycle,
+  type CmuxSessionLifecycleOptions,
+} from './cmux/session-lifecycle';
+import { CmuxSessionStore } from './cmux/session-state';
 
 type BackgroundJobReader = Pick<
   BackgroundJobStore,
@@ -45,7 +50,9 @@ interface SessionEvent {
       parentID?: string;
       title?: string;
       directory?: string;
+      sessionID?: string;
     };
+    part?: { sessionID?: string };
     sessionID?: string;
     status?: { type: string };
   };
@@ -78,8 +85,11 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
+  new CmuxSessionStore().resetForTests();
 }
 
+export type MultiplexerSessionManagerOptions = CmuxSessionLifecycleOptions;
+
 /**
  * Tracks child sessions and spawns/closes multiplexer panes for them.
  *
@@ -97,11 +107,13 @@ export class MultiplexerSessionManager {
   private closingSessions: SharedSessionState['closingSessions'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
+  private cmuxLifecycle?: CmuxSessionLifecycle;
 
   constructor(
     ctx: PluginInput,
     config: MultiplexerConfig,
     private readonly backgroundJobBoard?: BackgroundJobReader,
+    options: MultiplexerSessionManagerOptions = {},
   ) {
     const sharedState = getSharedState();
     this.sessions = sharedState.sessions;
@@ -119,6 +131,16 @@ export class MultiplexerSessionManager {
       config.type !== 'none' &&
       this.multiplexer !== null &&
       this.multiplexer.isInsideSession();
+    if (this.enabled && this.multiplexer?.type === 'cmux') {
+      this.cmuxLifecycle = new CmuxSessionLifecycle(
+        this.instanceId,
+        this.multiplexer,
+        this.serverUrl,
+        this.directory,
+        this.backgroundJobBoard,
+        options,
+      );
+    }
 
     log('[multiplexer-session-manager] initialized', {
       instanceId: this.instanceId,
@@ -131,6 +153,7 @@ export class MultiplexerSessionManager {
   }
 
   async onSessionCreated(event: SessionEvent): Promise<void> {
+    if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionCreated(event);
     if (!this.enabled || !this.multiplexer) return;
     if (event.type !== 'session.created') return;
 
@@ -241,6 +264,7 @@ export class MultiplexerSessionManager {
   }
 
   async onSessionStatus(event: SessionEvent): Promise<void> {
+    if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionStatus(event);
     if (!this.enabled) return;
 
     if (event.type === 'session.idle') {
@@ -299,6 +323,7 @@ export class MultiplexerSessionManager {
   }
 
   async onSessionDeleted(event: SessionEvent): Promise<void> {
+    if (this.cmuxLifecycle) return this.cmuxLifecycle.onSessionDeleted(event);
     if (!this.enabled) return;
     if (event.type !== 'session.deleted') return;
 
@@ -340,6 +365,7 @@ export class MultiplexerSessionManager {
   }
 
   private async pollSessions(): Promise<void> {
+    if (this.cmuxLifecycle) return this.cmuxLifecycle.pollOnce();
     if (this.sessions.size === 0) {
       this.stopPolling();
       return;
@@ -621,6 +647,8 @@ export class MultiplexerSessionManager {
   }
 
   async closeSessionFromCoordinator(sessionId: string): Promise<void> {
+    if (this.cmuxLifecycle)
+      return this.cmuxLifecycle.closeSessionFromCoordinator(sessionId);
     if (!this.enabled) return;
     // Coordinator already vetted lifecycle policy; skip re-check
     // ponytail: theoretical race if new job starts between coordinator's
@@ -629,6 +657,7 @@ export class MultiplexerSessionManager {
   }
 
   async cleanup(): Promise<void> {
+    if (this.cmuxLifecycle) return this.cmuxLifecycle.cleanup();
     this.stopPolling();
 
     if (this.closingSessions.size > 0) {
@@ -660,6 +689,10 @@ export class MultiplexerSessionManager {
 
     log('[multiplexer-session-manager] cleanup complete');
   }
+
+  async cleanupOnInstanceDisposed(): Promise<void> {
+    if (this.cmuxLifecycle) await this.cmuxLifecycle.cleanup();
+  }
 }
 
 /**

+ 38 - 0
src/multiplexer/shared.test.ts

@@ -104,6 +104,44 @@ describe('gracefulClosePane', () => {
 });
 
 describe('buildOpencodeAttachCommand', () => {
+  test('quotes an absolute executable containing spaces and apostrophes', async () => {
+    const { buildOpencodeAttachCommand } = await importShared();
+    const cmd = buildOpencodeAttachCommand(
+      'sess',
+      'url',
+      '/repo',
+      "/Users/King's Tools/opencode",
+    );
+    expect(cmd).toStartWith("'/Users/King'\\''s Tools/opencode' attach");
+  });
+
+  test('resolves host executable with env, process, and bare fallbacks', async () => {
+    const { resolveHostOpencodeBinary } = await importShared();
+    expect(
+      resolveHostOpencodeBinary({
+        envOverride: '/Users/king/.opencode/bin/opencode',
+        pathExists: () => true,
+        execPath: '/opt/homebrew/bin/bun',
+        argv0: '/opt/homebrew/bin/bun',
+      }),
+    ).toBe('/Users/king/.opencode/bin/opencode');
+    expect(
+      resolveHostOpencodeBinary({
+        envOverride: '/missing/opencode',
+        pathExists: (path) => path === '/Users/king/.opencode/bin/opencode',
+        execPath: '/Users/king/.opencode/bin/opencode',
+      }),
+    ).toBe('/Users/king/.opencode/bin/opencode');
+    expect(
+      resolveHostOpencodeBinary({
+        envOverride: 'relative/opencode',
+        pathExists: () => true,
+        execPath: '/opt/homebrew/bin/bun',
+        argv0: 'bun',
+      }),
+    ).toBeNull();
+  });
+
   test('normalizes Windows backslash paths to forward slashes', async () => {
     const original = process.platform;
     Object.defineProperty(process, 'platform', {

+ 32 - 1
src/multiplexer/shared.ts

@@ -5,6 +5,8 @@
  * Extracted to eliminate copy-paste duplication and prevent drift.
  */
 
+import { existsSync } from 'node:fs';
+import { basename, isAbsolute } from 'node:path';
 import { crossSpawn } from '../utils/compat';
 import { log } from '../utils/logger';
 
@@ -23,10 +25,11 @@ export function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,
   directory: string,
+  executable = 'opencode',
 ): string {
   const attachDir = normalizePathForShell(directory);
   return [
-    'opencode',
+    executable === 'opencode' ? executable : quoteShellArg(executable),
     'attach',
     quoteShellArg(serverUrl),
     '--session',
@@ -36,6 +39,34 @@ export function buildOpencodeAttachCommand(
   ].join(' ');
 }
 
+export function resolveHostOpencodeBinary(
+  options: {
+    override?: string;
+    envOverride?: string;
+    execPath?: string;
+    argv0?: string;
+    pathExists?: (path: string) => boolean;
+  } = {},
+): string | null {
+  const pathExists = options.pathExists ?? existsSync;
+  for (const candidate of [
+    options.override,
+    options.envOverride ?? process.env.OPENCODE_BIN,
+    options.execPath ?? process.execPath,
+    options.argv0 ?? process.argv[0],
+  ]) {
+    if (
+      candidate &&
+      isAbsolute(candidate) &&
+      /^opencode(?:\.exe)?$/i.test(basename(candidate)) &&
+      pathExists(candidate)
+    ) {
+      return candidate;
+    }
+  }
+  return null;
+}
+
 export async function findBinary(
   binaryName: string,
   options: { verify?: boolean } = {},

+ 5 - 2
src/multiplexer/types.ts

@@ -10,14 +10,17 @@ import type { MultiplexerConfig, MultiplexerLayout } from '../config/schema';
 export interface PaneResult {
   success: boolean;
   paneId?: string;
+  orphanPaneId?: string;
+  error?: 'unavailable' | 'not_found' | 'invalid_state' | 'hard';
 }
 
 /**
  * Core multiplexer interface
- * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer
+ * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer,
+ * CmuxMultiplexer
  */
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij' | 'herdr';
+  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux';
 
   /**
    * Check if the multiplexer binary is available on the system