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

feat(kitty): add kitty terminal multiplexer backend

Adds a kitty multiplexer that opens subagent panes via kitty's remote
control CLI (kitten @ launch). OpenCode must run inside a kitty window
(KITTY_PID/KITTY_WINDOW_ID) and kitty.conf must set allow_remote_control
and listen_on.

Key behaviors:
- Remote control goes through the KITTY_LISTEN_ON UNIX socket (inferred
  from the env kitty exports to its children) instead of the controlling
  tty, which does not work for OpenCode's detached subagent processes.
- Panes launch in the user's actual shell (detected via SHELL: fish, nu,
  zsh, bash, cmd, powershell, or default) with the absolute opencode
  binary, so the attach command resolves correctly under any shell.
- Plugin layouts map to kitty built-ins (main-vertical->tall,
  main-horizontal->fat, tiled->grid, even-*->horizontal/vertical).
- Graceful close sends Ctrl+C then closes the window; spawn/close/apply
  are guarded when not inside kitty or when KITTY_LISTEN_ON is absent
  (clear actionable error instead of silent failure).
- closePane threads KITTY_LISTEN_ON through to match spawnPane/ensureLayout.

Includes schema enum, factory wiring, docs, README, and tests.
adikpb 2 недель назад
Родитель
Сommit
fa36caa5b2

+ 2 - 2
CONTEXT.md

@@ -31,8 +31,8 @@ A glossary of the terms used in this project's domain. Definitions describe what
 
 ## Multiplexer & Sessions
 
-- **Multiplexer** — A terminal backend (tmux, zellij, or herdr) that hosts child agent panes. Set via \`multiplexer.type\`, which also accepts \`auto\` (auto-detect) and \`none\` (disabled).
-- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, or `none`.
+- **Multiplexer** — A terminal backend (tmux, zellij, herdr, or kitty) that hosts child agent panes. Set via \`multiplexer.type\`, which also accepts \`auto\` (auto-detect) and \`none\` (disabled).
+- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, `kitty`, or `none`.
 - **Pane** — A terminal region spawned by the multiplexer to run a child agent session.
 - **Child session** — A background agent session hosted in a multiplexer pane and tracked by the session manager.
 - **Session manager** — Tracks child sessions, spawns and closes multiplexer panes, and reacts to session lifecycle events. Note: `TmuxSessionManager` is a deprecated alias — use `MultiplexerSessionManager`.

+ 3 - 3
README.ja-JP.md

@@ -35,7 +35,7 @@ oh-my-opencode-slim は OpenCode 向けのエージェントオーケストレ
 - **[同梱スキル](#skills)** - `deepwork`、`codemap`、`verification-planning`、`reflect` などのプロンプトベースのワークフローを、エージェントごとに割り当てます。
 - **[Council](docs/council.md)** - `@council` で同じ質問を複数モデルに並列で投げ、1 つの回答に統合します。
 - **[Companion](docs/companion.md)** - 並列のバックグラウンド専門家を含む、稼働中のエージェントを表示する任意のフローティングデスクトップウィンドウです。
-- **[マルチプレクサー統合](docs/multiplexer-integration.md)** - Tmux、Zellij、Herdr、cmux のペインでエージェントの作業をライブ表示します。
+- **[マルチプレクサー統合](docs/multiplexer-integration.md)** - Tmux、Zellij、Herdr、cmux、kitty のペインでエージェントの作業をライブ表示します。
 - **[プリセット切り替え](docs/preset-switching.md)** - `/preset` でチーム全体のモデルを実行時に切り替えます。
 - **[コードインテリジェンスツール](docs/tools.md)** - 25 言語対応の LSP、AST 対応検索、Web 検索・ドキュメント・GitHub コード検索用の組み込み MCP を提供します。
 - **[完全にカスタマイズ可能](docs/configuration.md)** - カスタムエージェント、プロンプト上書き、エージェントごとのスキル/MCP 権限、[プロジェクトローカルのカスタマイズ](docs/project-local-customization.md)に対応します。
@@ -143,7 +143,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 または cmux ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
+> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux、Zellij、Herdr、cmux、または kitty ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
 
 デフォルトで生成される設定には `openai` と `opencode-go` の両方のプリセットが含まれます。
 
@@ -607,7 +607,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[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 や cmux のペインでライブ表示します |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux、Zellij、Herdr、cmux、や kitty のペインでライブ表示します |
 | **[Codemap](docs/codemap.md)** | 階層的なコードマップを生成し、大規模コードベースを迅速に理解します |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |

+ 3 - 3
README.ko-KR.md

@@ -44,7 +44,7 @@ oh-my-opencode-slim은 OpenCode용 에이전트 오케스트레이션 플러그
 - **[Companion](docs/companion.md)** - 병렬 백그라운드 전문 에이전트를 포함해
   활성 에이전트를 보여 주는 선택적 플로팅 데스크톱 창입니다.
 - **[멀티플렉서 통합](docs/multiplexer-integration.md)** - Tmux, Zellij,
-  Herdr 또는 cmux 페인에서 에이전트 작업을 실시간으로 확인합니다.
+  Herdr, cmux, 또는 kitty 페인에서 에이전트 작업을 실시간으로 확인합니다.
 - **[프리셋 전환](docs/preset-switching.md)** - `/preset`으로 실행 중에 팀 전체의
   모델을 교체합니다.
 - **[코드 인텔리전스 도구](docs/tools.md)** - 25개 언어를 지원하는 LSP 도구와
@@ -154,7 +154,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 또는 cmux 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
+> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux, Zellij, Herdr, cmux, 또는 kitty 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
 
 기본 생성 설정에는 `openai`와 `opencode-go` 프리셋이 모두 포함되어 있습니다.
 
@@ -628,7 +628,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[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 또는 cmux 페인에서 에이전트 작업을 실시간으로 확인 |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux, Zellij, Herdr, cmux, 또는 kitty 페인에서 에이전트 작업을 실시간으로 확인 |
 | **[Codemap](docs/codemap.md)** | 계층형 코드맵을 생성하여 대규모 코드베이스를 빠르게 파악 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |

+ 3 - 3
README.md

@@ -43,7 +43,7 @@ The main idea is simple: instead of forcing one model to do everything, the plug
 - **[Companion](docs/companion.md)** - an optional floating desktop window
   showing which agents are active, including parallel background specialists.
 - **[Multiplexer integration](docs/multiplexer-integration.md)** - watch agents
-  work live in Tmux, Zellij, Herdr, or cmux panes.
+  work live in Tmux, Zellij, Herdr, cmux, or kitty panes.
 - **[Preset switching](docs/preset-switching.md)** - swap the whole team's
   models at runtime with `/preset`.
 - **[Code intelligence tools](docs/tools.md)** - LSP tools, AST-aware search
@@ -155,7 +155,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, Herdr, or cmux 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, cmux, or kitty 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.
 
@@ -631,7 +631,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, Herdr, or cmux panes |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux, Zellij, Herdr, cmux, or kitty 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 |

+ 1 - 1
docs/configuration.md

@@ -137,7 +137,7 @@ 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`, `cmux`, or `none` |
+| `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, `kitty`, 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 |

+ 51 - 4
docs/multiplexer-integration.md

@@ -1,7 +1,7 @@
 # Multiplexer Integration Guide
 
-Use tmux, Zellij, Herdr, or cmux to watch subagents work in live panes while
-OpenCode keeps running in your main session.
+Use tmux, Zellij, Herdr, cmux, or kitty to watch subagents work in live panes
+while OpenCode keeps running in your main session.
 
 ## Table of Contents
 
@@ -143,7 +143,34 @@ Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 
 cmux 0.64.14 or newer is required; 0.64.17 or newer is recommended.
 
-### 2. Start OpenCode inside tmux, Zellij, Herdr, or cmux
+**Kitty only:**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "kitty"
+  }
+}
+```
+
+Kitty requires `allow_remote_control` **and** `listen_on` in `kitty.conf`.
+`listen_on` opens a UNIX socket and kitty exports `KITTY_LISTEN_ON` to its
+child processes (including OpenCode). The plugin passes that env through to
+every `kitten @` invocation automatically — no extra plugin config is needed.
+This is required because OpenCode spawns subagent commands in a process
+detached from the kitty window's controlling terminal, where the tty-based
+remote-control path does not work.
+
+```conf
+allow_remote_control yes
+listen_on unix:/tmp/kitty-rc-$(USER)
+```
+
+After editing `kitty.conf`, **restart kitty** (quit and relaunch) so the socket
+is created. Verify with `kitten @ ls` from a normal shell — it should print JSON
+instead of timing out.
+
+### 2. Start OpenCode inside tmux, Zellij, Herdr, cmux, or kitty
 
 **Tmux:**
 
@@ -169,6 +196,13 @@ 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`.
 
+**Kitty:**
+
+```bash
+kitty
+opencode --port 4096
+```
+
 ### 3. Trigger delegated work
 
 Ask OpenCode to do something that launches subagents. New panes should appear automatically.
@@ -197,7 +231,7 @@ Please analyze this codebase and create a documentation structure.
 
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
-| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, `"herdr"`, `"cmux"`, or `"none"` |
+| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, `"herdr"`, `"cmux"`, `"kitty"`, 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 |
@@ -210,6 +244,7 @@ Please analyze this codebase and create a documentation structure.
 | **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 |
+| **Kitty** | ✅ Supported | Uses `kitten @ launch` to open new windows; requires `allow_remote_control` **and** `listen_on` in kitty.conf (OpenCode must run inside a kitty window; kitty exports `KITTY_LISTEN_ON` which the plugin passes through to reach kitty from detached subagent processes). No layout rebalancing (like Zellij/Herdr) |
 
 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;
@@ -315,6 +350,18 @@ For Zellij:
 | `even-vertical` | Uses Zellij's native pane placement |
 | `tiled` | Uses Zellij's native pane placement |
 
+For kitty:
+
+| Layout | Kitty behavior |
+|--------|----------------|
+| `main-vertical` | Uses `tall` layout (full-height main pane on left, side panes stacked on right) |
+| `main-horizontal` | Uses `fat` layout (full-width main pane on top, side panes tiled below) |
+| `even-horizontal` | Uses `horizontal` layout (all panes side-by-side, equal width) |
+| `even-vertical` | Uses `vertical` layout (all panes stacked, equal height) |
+| `tiled` | Uses `grid` layout (all panes in equal-sized grid) |
+
+> **Note:** kitty has no layout rebalancing API like tmux's `select-layout`, and no per-window layout — so the multiplexer applies the mapped kitty layout (`tall`, `fat`, `grid`, `horizontal`, or `vertical`) as a **global change to the active tab**, overriding whatever layout you had there. The `main_pane_size` config is ignored. The layout is only re-applied when it differs from the currently applied one (e.g., via `applyLayout`).
+
 For Herdr:
 
 | Layout | Herdr behavior |

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

@@ -941,6 +941,7 @@
             "tmux",
             "zellij",
             "herdr",
+            "kitty",
             "cmux",
             "none"
           ]

+ 1 - 0
src/config/schema.ts

@@ -131,6 +131,7 @@ export const MultiplexerTypeSchema = z.enum([
   'tmux',
   'zellij',
   'herdr',
+  'kitty',
   'cmux',
   'none',
 ]);

+ 10 - 6
src/multiplexer/codemap.md

@@ -2,7 +2,8 @@
 
 ## Responsibility
 
-Provides a unified abstraction for tmux, Zellij, Herdr, and cmux to spawn,
+Provides a unified abstraction for tmux, Zellij, Herdr, cmux, and kitty to
+spawn,
 manage, and close panes for child OpenCode agent sessions.
 
 ## Design
@@ -14,6 +15,7 @@ manage, and close panes for child OpenCode agent sessions.
   - `TmuxMultiplexer`: tmux-specific implementation using `tmux` CLI commands
   - `ZellijMultiplexer`: zellij-specific implementation using zellij plugin API
   - `HerdrMultiplexer`: herdr-specific implementation using `herdr` CLI commands
+  - `KittyMultiplexer`: kitty-specific implementation using `kitten @` 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.
@@ -32,7 +34,7 @@ manage, and close panes for child OpenCode agent sessions.
 
 ```typescript
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux';
+  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux' | 'kitty';
   isAvailable(): Promise<boolean>;
   isInsideSession(): boolean;
   spawnPane(sessionId: string, description: string, serverUrl: string, directory: string): Promise<PaneResult>;
@@ -129,7 +131,7 @@ The session manager reacts to OpenCode session events:
 
 ```typescript
 interface MultiplexerConfig {
-  type: 'tmux' | 'zellij' | 'herdr' | 'cmux' | 'auto' | 'none';
+  type: 'tmux' | 'zellij' | 'herdr' | 'cmux' | 'kitty' | '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
@@ -138,9 +140,10 @@ interface MultiplexerConfig {
 
 ### Environment Detection
 
-- **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).
+- **Auto Mode**: Detects tmux (`TMUX`), Zellij (`ZELLIJ`), kitty
+  (`KITTY_PID`), 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
@@ -184,6 +187,7 @@ interface MultiplexerConfig {
 | `tmux/index.ts` | tmux-specific implementation |
 | `zellij/index.ts` | zellij-specific implementation |
 | `herdr/index.ts` | herdr-specific implementation |
+| `kitty/index.ts` | kitty-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 |

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

@@ -12,11 +12,15 @@ describe('multiplexer factory', () => {
   const originalCmuxSocket = process.env.CMUX_SOCKET_PATH;
   const originalCmuxWorkspace = process.env.CMUX_WORKSPACE_ID;
   const originalCmuxSurface = process.env.CMUX_SURFACE_ID;
+  const originalKittyPid = process.env.KITTY_PID;
+  const originalKittyWindowId = process.env.KITTY_WINDOW_ID;
 
   beforeEach(() => {
     delete process.env.CMUX_SOCKET_PATH;
     delete process.env.CMUX_WORKSPACE_ID;
     delete process.env.CMUX_SURFACE_ID;
+    delete process.env.KITTY_PID;
+    delete process.env.KITTY_WINDOW_ID;
   });
 
   afterEach(() => {
@@ -27,6 +31,8 @@ describe('multiplexer factory', () => {
     process.env.CMUX_SOCKET_PATH = originalCmuxSocket;
     process.env.CMUX_WORKSPACE_ID = originalCmuxWorkspace;
     process.env.CMUX_SURFACE_ID = originalCmuxSurface;
+    process.env.KITTY_PID = originalKittyPid;
+    process.env.KITTY_WINDOW_ID = originalKittyWindowId;
   });
 
   test('returns a fresh tmux instance per call', async () => {
@@ -173,4 +179,38 @@ describe('multiplexer factory', () => {
       })?.type,
     ).toBe('cmux');
   });
+
+  test('returns a kitty instance when type is kitty', async () => {
+    delete process.env.KITTY_PID;
+    const { getMultiplexer } = await importFreshFactory('kitty-explicit');
+    const multiplexer = getMultiplexer({
+      type: 'kitty',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('kitty');
+  });
+
+  test('auto-detects kitty when KITTY_PID is set', async () => {
+    delete process.env.TMUX;
+    delete process.env.TMUX_PANE;
+    delete process.env.ZELLIJ;
+    delete process.env.HERDR_ENV;
+    delete process.env.HERDR_PANE_ID;
+    delete process.env.CMUX_SOCKET_PATH;
+    delete process.env.CMUX_WORKSPACE_ID;
+    delete process.env.CMUX_SURFACE_ID;
+    process.env.KITTY_PID = '12345';
+    const { getMultiplexer } = await importFreshFactory('auto-kitty');
+    expect(
+      getMultiplexer({
+        type: 'auto',
+        layout: 'main-vertical',
+        main_pane_size: 60,
+        zellij_pane_mode: 'agent-tab',
+      })?.type,
+    ).toBe('kitty');
+  });
 });

+ 11 - 0
src/multiplexer/factory.ts

@@ -6,6 +6,7 @@ import type { MultiplexerConfig, MultiplexerType } from '../config/schema';
 import { log } from '../utils/logger';
 import { CmuxMultiplexer } from './cmux';
 import { HerdrMultiplexer } from './herdr';
+import { KittyMultiplexer } from './kitty';
 import { TmuxMultiplexer } from './tmux';
 import type { Multiplexer } from './types';
 import { ZellijMultiplexer } from './zellij';
@@ -49,6 +50,10 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       multiplexer = new CmuxMultiplexer();
       actualType = 'cmux';
       break;
+    case 'kitty':
+      multiplexer = new KittyMultiplexer(config.layout, config.main_pane_size);
+      actualType = 'kitty';
+      break;
     case 'auto': {
       // Auto-detect based on environment variables only
       // Note: Does NOT fall back to binary availability checks
@@ -69,6 +74,12 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
           config.zellij_pane_mode,
         );
         actualType = 'zellij';
+      } else if (process.env.KITTY_PID || process.env.KITTY_WINDOW_ID) {
+        multiplexer = new KittyMultiplexer(
+          config.layout,
+          config.main_pane_size,
+        );
+        actualType = 'kitty';
       } else if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
         multiplexer = new HerdrMultiplexer(
           config.layout,

+ 1 - 0
src/multiplexer/index.ts

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

+ 311 - 0
src/multiplexer/kitty/index.test.ts

@@ -0,0 +1,311 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const logMock = mock(() => {});
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../../utils/logger', () => ({
+  log: logMock,
+}));
+
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(
+  exitCode = 0,
+  stdout = '',
+  stderr = '',
+): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(stdout),
+    stderr: () => Promise.resolve(stderr),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+async function importFreshKitty() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+describe('KittyMultiplexer', () => {
+  const originalKittyPid = process.env.KITTY_PID;
+  const originalKittyWindowId = process.env.KITTY_WINDOW_ID;
+
+  beforeEach(() => {
+    process.env.KITTY_PID = '12345';
+    process.env.KITTY_WINDOW_ID = '1';
+
+    logMock.mockClear();
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      // which kitten
+      if (command[0] === 'which' && command[1] === 'kitten') {
+        return createSpawnResult(0, '/usr/bin/kitten\n');
+      }
+      // which kitty (fallback)
+      if (command[0] === 'which' && command[1] === 'kitty') {
+        return createSpawnResult(1, '');
+      }
+      // kitten @ launch
+      if (command.includes('@') && command.includes('launch')) {
+        return createSpawnResult(0, '42\n');
+      }
+      return createSpawnResult();
+    });
+  });
+
+  afterEach(() => {
+    process.env.KITTY_PID = originalKittyPid;
+    process.env.KITTY_WINDOW_ID = originalKittyWindowId;
+  });
+
+  test('isAvailable returns true when kitten is found', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+    expect(await kitty.isAvailable()).toBe(true);
+  });
+
+  test('isAvailable returns false when kitten is not found', async () => {
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' && command[1] === 'kitten') {
+        return createSpawnResult(1, '');
+      }
+      if (command[0] === 'which' && command[1] === 'kitty') {
+        return createSpawnResult(1, '');
+      }
+      return createSpawnResult();
+    });
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+    expect(await kitty.isAvailable()).toBe(false);
+  });
+
+  test('isInsideSession returns true when KITTY_PID is set', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+    expect(kitty.isInsideSession()).toBe(true);
+  });
+
+  test('isInsideSession returns false when KITTY_PID is not set', async () => {
+    delete process.env.KITTY_PID;
+    delete process.env.KITTY_WINDOW_ID;
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+    expect(kitty.isInsideSession()).toBe(false);
+  });
+
+  test('spawnPane returns failure when not inside a kitty session', async () => {
+    delete process.env.KITTY_PID;
+    delete process.env.KITTY_WINDOW_ID;
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    const result = await kitty.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result.success).toBe(false);
+    // No kitten @ launch should have been attempted.
+    const launchCmds = commands().filter(
+      (c) => c.includes('@') && c.includes('launch'),
+    );
+    expect(launchCmds.length).toBe(0);
+  });
+
+  test('closePane returns false when not inside a kitty session', async () => {
+    delete process.env.KITTY_PID;
+    delete process.env.KITTY_WINDOW_ID;
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    const closed = await kitty.closePane('42');
+    expect(closed).toBe(false);
+  });
+
+  test('applyLayout is a no-op when not inside a kitty session', async () => {
+    delete process.env.KITTY_PID;
+    delete process.env.KITTY_WINDOW_ID;
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('tiled', 60);
+
+    const layoutCmds = commands().filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBe(0);
+  });
+
+  test('spawnPane parses integer window id from launch stdout', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    const result = await kitty.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result.success).toBe(true);
+    expect(result.paneId).toBe('42');
+  });
+
+  test('spawnPane returns failure when exit code is non-zero', async () => {
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which' && command[1] === 'kitten') {
+        return createSpawnResult(0, '/usr/bin/kitten\n');
+      }
+      if (command.includes('@') && command.includes('launch')) {
+        return createSpawnResult(1, '', 'launch failed');
+      }
+      return createSpawnResult();
+    });
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    const result = await kitty.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result.success).toBe(false);
+  });
+
+  test('closePane issues send-key then close-window', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    const closed = await kitty.closePane('42');
+    expect(closed).toBe(true);
+
+    const cmds = commands();
+    const sendKeyCmds = cmds.filter((c) => c.includes('send-key'));
+    const closeWindowCmds = cmds.filter((c) => c.includes('close-window'));
+
+    expect(sendKeyCmds.length).toBeGreaterThanOrEqual(1);
+    expect(closeWindowCmds.length).toBeGreaterThanOrEqual(1);
+
+    // Verify correct arguments
+    const sendKeyArgs = sendKeyCmds[0];
+    expect(sendKeyArgs).toContain('@');
+    expect(sendKeyArgs).toContain('send-key');
+    expect(sendKeyArgs).toContain('--match');
+    expect(sendKeyArgs).toContain('id:42');
+    expect(sendKeyArgs).toContain('ctrl+c');
+
+    const closeArgs = closeWindowCmds[0];
+    expect(closeArgs).toContain('@');
+    expect(closeArgs).toContain('close-window');
+    expect(closeArgs).toContain('--match');
+    expect(closeArgs).toContain('id:42');
+  });
+
+  test('applyLayout issues goto-layout tall for main-vertical', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('main-vertical', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBeGreaterThanOrEqual(1);
+    expect(layoutCmds[0]).toContain('goto-layout');
+    expect(layoutCmds[0]).toContain('tall');
+  });
+
+  test('applyLayout issues goto-layout fat for main-horizontal', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('main-horizontal', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBeGreaterThanOrEqual(1);
+    expect(layoutCmds[0]).toContain('goto-layout');
+    expect(layoutCmds[0]).toContain('fat');
+  });
+
+  test('applyLayout issues goto-layout grid for tiled', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('tiled', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBeGreaterThanOrEqual(1);
+    expect(layoutCmds[0]).toContain('goto-layout');
+    expect(layoutCmds[0]).toContain('grid');
+  });
+
+  test('applyLayout issues goto-layout horizontal for even-horizontal', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('even-horizontal', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBeGreaterThanOrEqual(1);
+    expect(layoutCmds[0]).toContain('goto-layout');
+    expect(layoutCmds[0]).toContain('horizontal');
+  });
+
+  test('applyLayout issues goto-layout vertical for even-vertical', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer();
+
+    await kitty.applyLayout('even-vertical', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBeGreaterThanOrEqual(1);
+    expect(layoutCmds[0]).toContain('goto-layout');
+    expect(layoutCmds[0]).toContain('vertical');
+  });
+
+  test('applyLayout switches layout after a spawn', async () => {
+    const { KittyMultiplexer } = await importFreshKitty();
+    const kitty = new KittyMultiplexer('main-vertical');
+
+    // First spawn applies 'tall'
+    await kitty.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    // applyLayout to a different layout must re-apply (goto-layout grid)
+    await kitty.applyLayout('tiled', 60);
+
+    const cmds = commands();
+    const layoutCmds = cmds.filter((c) => c.includes('goto-layout'));
+    expect(layoutCmds.length).toBe(2);
+    expect(layoutCmds[0]).toContain('tall');
+    expect(layoutCmds[1]).toContain('grid');
+  });
+});

+ 338 - 0
src/multiplexer/kitty/index.ts

@@ -0,0 +1,338 @@
+/**
+ * Kitty multiplexer implementation
+ *
+ * Uses kitty's remote-control CLI (`kitten @`) to manage windows.
+ * A kitty WINDOW is the 1:1 equivalent of a tmux pane (full PTY).
+ *
+ * Requires `allow_remote_control` **and** `listen_on` in kitty.conf. When
+ * `listen_on` is set, kitty exports `KITTY_LISTEN_ON` to its child processes
+ * (including OpenCode). The plugin passes that env through to `kitten @` so
+ * remote control goes via the socket instead of the controlling terminal,
+ * because OpenCode spawns subagent commands in a process detached from the
+ * kitty window's tty, where the tty-based remote-control path does not work.
+ *
+ * Layout mapping:
+ * - main-vertical → tall layout (full-height main pane on left, side panes stacked on right)
+ * - main-horizontal → fat layout (full-width main pane on top, side panes tiled below)
+ * - tiled → grid layout (equal-sized cells)
+ * - even-horizontal → horizontal layout (all panes side-by-side)
+ * - even-vertical → vertical layout (all panes stacked)
+ *
+ * Maps each plugin layout to the closest kitty built-in layout. kitty has no
+ * per-window layout API, so the chosen layout is applied as a global change to
+ * the active tab (overriding whatever layout was active there).
+ */
+
+import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
+import { log } from '../../utils/logger';
+import {
+  buildOpencodeAttachCommand,
+  buildShellLaunchArgs,
+  findBinary,
+  gracefulClosePane,
+  normalizePathForShell,
+  resolveOpencodeExecutable,
+} from '../shared';
+import type { Multiplexer, PaneResult } from '../types';
+
+export class KittyMultiplexer implements Multiplexer {
+  readonly type = 'kitty' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private storedLayout: MultiplexerLayout;
+  private appliedLayout: string | null = null;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    void mainPaneSize; // kitty uses layout bias, not main pane size
+    this.storedLayout = layout;
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.hasChecked) {
+      return this.binaryPath !== null;
+    }
+
+    // Try `kitten` first (the recommended remote-control CLI). Fall back to
+    // `kitty` only if `kitten` is absent — `kitty @` is an alias for
+    // `kitten @` (remote control), not a request to open a new window.
+    this.binaryPath =
+      (await findBinary('kitten')) ?? (await findBinary('kitty'));
+    this.hasChecked = true;
+    return this.binaryPath !== null;
+  }
+
+  isInsideSession(): boolean {
+    return !!process.env.KITTY_PID || !!process.env.KITTY_WINDOW_ID;
+  }
+
+  /**
+   * The kitty multiplexer needs `listen_on` configured in kitty.conf so kitty
+   * exports `KITTY_LISTEN_ON` to its children. Without it, `kitten @` cannot
+   * reach kitty from OpenCode's detached subagent processes (the tty path does
+   * not work). Returns true when the socket env is present.
+   */
+  private hasListenOn(): boolean {
+    return !!process.env.KITTY_LISTEN_ON;
+  }
+
+  async spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+    directory: string,
+  ): Promise<PaneResult> {
+    const kitten = await this.getBinary();
+    if (!kitten) {
+      log('[kitty] spawnPane: kitten/kitty binary not found');
+      return { success: false };
+    }
+
+    // The kitty multiplexer only works when OpenCode is itself running inside
+    // a kitty window (so `kitten @` can reach the instance). If not, fail
+    // cleanly instead of shelling out to a command that cannot succeed.
+    if (!this.isInsideSession()) {
+      log(
+        '[kitty] spawnPane: OpenCode is not running inside a kitty session; ' +
+          'set multiplexer.type to a different backend or run OpenCode inside kitty',
+      );
+      return { success: false };
+    }
+
+    // `listen_on` must be set in kitty.conf so kitty exports KITTY_LISTEN_ON.
+    // Without it, remote control cannot reach kitty from detached subagent
+    // processes. Fail with an actionable message instead of a silent error.
+    if (!this.hasListenOn()) {
+      log(
+        '[kitty] spawnPane: KITTY_LISTEN_ON is not set. Add `listen_on ' +
+          'unix:/tmp/kitty-rc-$(USER)` to kitty.conf and restart kitty so the ' +
+          'plugin can drive kitty via the socket.',
+      );
+      return { success: false };
+    }
+
+    // Map layout to kitty layout and location
+    const { kittyLayout, location } = getKittyLayoutConfig(this.storedLayout);
+
+    // Ensure the correct layout is active
+    await this.ensureLayout(kittyLayout);
+
+    try {
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+        resolveOpencodeExecutable(),
+      );
+
+      // Normalize for Windows/MSYS2/Git Bash (backslashes would be treated as
+      // escape chars). No-op on macOS/Linux. Mirrors the herdr adapter.
+      const attachDir = normalizePathForShell(directory);
+
+      // Launch in the user's interactive shell (fish/bash/zsh/sh) so the
+      // command resolves correctly — a hardcoded `sh -c` breaks under fish
+      // and misses login startup files where `opencode` may live on PATH.
+      const shellArgs = buildShellLaunchArgs(opencodeCmd);
+
+      const args = [
+        '@',
+        'launch',
+        '--type=window',
+        `--location=${location}`,
+        `--title=${description.slice(0, 60)}`,
+        `--cwd=${attachDir}`,
+        '--keep-focus',
+        '--',
+        ...shellArgs,
+      ];
+
+      log('[kitty] spawnPane: executing', { kitten, args });
+
+      const proc = crossSpawn([kitten, ...args], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+        env: this.kittyEnv(),
+      });
+
+      const exitCode = await proc.exited;
+      const stdout = await proc.stdout();
+      const stderr = await proc.stderr();
+
+      // Kitty prints the integer window id on stdout (last non-empty line)
+      const lines = stdout
+        .trim()
+        .split('\n')
+        .map((l) => l.trim())
+        .filter(Boolean);
+      const windowId = lines.length > 0 ? lines[lines.length - 1] : '';
+
+      log('[kitty] spawnPane: result', {
+        exitCode,
+        windowId,
+        stderr: stderr.trim(),
+      });
+
+      if (exitCode === 0 && windowId) {
+        log('[kitty] spawnPane: SUCCESS', { windowId });
+        return { success: true, paneId: windowId };
+      }
+
+      return { success: false };
+    } catch (err) {
+      log('[kitty] spawnPane: exception', { error: String(err) });
+      return { success: false };
+    }
+  }
+
+  async closePane(paneId: string): Promise<boolean> {
+    if (!this.isInsideSession()) {
+      log(
+        '[kitty] closePane: OpenCode is not running inside a kitty session; ' +
+          'cannot target a kitty instance',
+      );
+      return false;
+    }
+    if (!this.hasListenOn()) {
+      log(
+        '[kitty] closePane: KITTY_LISTEN_ON is not set; cannot target kitty. ' +
+          'Add `listen_on` to kitty.conf and restart kitty.',
+      );
+      return false;
+    }
+    const kitten = await this.getBinary();
+    return await gracefulClosePane(kitten, paneId, {
+      ctrlC: ['@', 'send-key', '--match', `id:${paneId}`, 'ctrl+c'],
+      close: ['@', 'close-window', '--match', `id:${paneId}`],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+      env: this.kittyEnv(),
+    });
+  }
+
+  async applyLayout(
+    layout: MultiplexerLayout,
+    mainPaneSize: number,
+  ): Promise<void> {
+    void mainPaneSize; // kitty uses layout bias, not main pane size
+    if (!this.isInsideSession()) {
+      log(
+        '[kitty] applyLayout: OpenCode is not running inside a kitty session; ' +
+          'cannot target a kitty instance',
+      );
+      return;
+    }
+    if (!this.hasListenOn()) {
+      log(
+        '[kitty] applyLayout: KITTY_LISTEN_ON is not set; cannot target kitty. ' +
+          'Add `listen_on` to kitty.conf and restart kitty.',
+      );
+      return;
+    }
+    this.storedLayout = layout;
+    const { kittyLayout } = getKittyLayoutConfig(layout);
+    await this.ensureLayout(kittyLayout);
+  }
+
+  private async runKitty(kitten: string, args: string[]): Promise<number> {
+    const proc = crossSpawn([kitten, ...args], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+      env: this.kittyEnv(),
+    });
+    const [exitCode, , stderr] = await Promise.all([
+      proc.exited,
+      proc.stdout(),
+      proc.stderr(),
+    ]);
+
+    if (exitCode !== 0) {
+      log('[kitty] command failed', {
+        command: args[1],
+        args: [kitten, ...args],
+        exitCode,
+        stderr: stderr.trim(),
+      });
+    }
+
+    return exitCode;
+  }
+
+  private async ensureLayout(kittyLayout: string): Promise<void> {
+    // Skip if this layout is already applied (avoids redundant global switches).
+    // Tracking the applied layout (not a boolean) lets applyLayout switch to a
+    // different layout even after panes have spawned.
+    if (this.appliedLayout === kittyLayout) return;
+    const kitten = await this.getBinary();
+    if (!kitten) return;
+    try {
+      const exitCode = await this.runKitty(kitten, [
+        '@',
+        'goto-layout',
+        kittyLayout,
+      ]);
+      // Only record on success so a transient failure can retry.
+      if (exitCode === 0) this.appliedLayout = kittyLayout;
+    } catch (err) {
+      log('[kitty] ensureLayout: exception', { error: String(err) });
+    }
+  }
+
+  private async getBinary(): Promise<string | null> {
+    await this.isAvailable();
+    return this.binaryPath;
+  }
+
+  /**
+   * Build the env for `kitten @` invocations. When kitty's `listen_on` socket
+   * is configured, kitty sets `KITTY_LISTEN_ON` in its child processes (which
+   * includes OpenCode). We pass that env through so remote control goes via the
+   * socket instead of the controlling terminal (required for the plugin's
+   * detached subagent processes). When the var is absent we return undefined
+   * and `kitten @` falls back to the tty path.
+   */
+  private kittyEnv(): Record<string, string | undefined> | undefined {
+    const listenOn = process.env.KITTY_LISTEN_ON;
+    if (!listenOn) return undefined;
+    return { ...process.env, KITTY_LISTEN_ON: listenOn };
+  }
+}
+
+/**
+ * Map plugin layout to kitty layout and launch location.
+ *
+ * Each plugin layout maps to the closest kitty built-in layout
+ * (tall/fat/grid/horizontal/vertical). kitty has no per-window layout, so the
+ * chosen layout is applied globally to the active tab. `--location=after` is
+ * used for spawning since the layout engine places new windows.
+ */
+function getKittyLayoutConfig(layout: MultiplexerLayout): {
+  kittyLayout: string;
+  location: string;
+} {
+  switch (layout) {
+    case 'main-vertical':
+      // tall = full-height main pane on left, side panes stacked on right
+      // after = place new window after active window (stacked vertically)
+      return { kittyLayout: 'tall', location: 'after' };
+    case 'main-horizontal':
+      // fat = full-width main pane on top, side panes tiled below
+      // after = place new window after active window (tiled horizontally)
+      return { kittyLayout: 'fat', location: 'after' };
+    case 'tiled':
+      // grid = all windows in a balanced grid, same size
+      return { kittyLayout: 'grid', location: 'after' };
+    case 'even-horizontal':
+      // horizontal = all windows side-by-side, equal width
+      return { kittyLayout: 'horizontal', location: 'after' };
+    case 'even-vertical':
+      // vertical = all windows stacked, equal height
+      return { kittyLayout: 'vertical', location: 'after' };
+    default: {
+      // Exhaustiveness check: a new MultiplexerLayout value must be added
+      // above, otherwise this assignment fails to type-check.
+      const _exhaustive: never = layout;
+      return _exhaustive;
+    }
+  }
+}

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

@@ -182,3 +182,74 @@ describe('buildOpencodeAttachCommand', () => {
     }
   });
 });
+
+describe('buildShellLaunchArgs', () => {
+  const cases: Array<{
+    shell: string;
+    expected: (cmd: string) => string[];
+  }> = [
+    {
+      shell: '/opt/homebrew/bin/fish',
+      expected: (cmd) => ['/opt/homebrew/bin/fish', '-c', cmd],
+    },
+    {
+      shell: '/usr/bin/nu',
+      expected: (cmd) => ['/usr/bin/nu', '-c', cmd],
+    },
+    {
+      shell: '/bin/zsh',
+      expected: (cmd) => ['/bin/zsh', '-l', '-c', expect.stringContaining(cmd)],
+    },
+    {
+      shell: '/bin/bash',
+      expected: (cmd) => [
+        '/bin/bash',
+        '-l',
+        '-c',
+        expect.stringContaining(cmd),
+      ],
+    },
+    {
+      shell: 'C:\\Windows\\System32\\cmd.exe',
+      expected: (cmd) => ['C:\\Windows\\System32\\cmd.exe', '/c', cmd],
+    },
+    {
+      shell: '/usr/bin/pwsh',
+      expected: (cmd) => ['/usr/bin/pwsh', '-NoProfile', '-Command', cmd],
+    },
+    {
+      shell: '/bin/dash',
+      expected: (cmd) => ['/bin/dash', '-c', cmd],
+    },
+    {
+      shell: '/usr/bin/elvish',
+      expected: (cmd) => ['/usr/bin/elvish', '-c', cmd],
+    },
+  ];
+
+  for (const { shell, expected } of cases) {
+    test(`uses correct args for ${shell}`, async () => {
+      const original = process.env.SHELL;
+      process.env.SHELL = shell;
+      try {
+        const { buildShellLaunchArgs } = await importShared();
+        const cmd = 'opencode attach url --session s';
+        expect(buildShellLaunchArgs(cmd)).toEqual(expected(cmd));
+      } finally {
+        process.env.SHELL = original;
+      }
+    });
+  }
+
+  test('falls back to /bin/sh when SHELL is unset', async () => {
+    const original = process.env.SHELL;
+    delete process.env.SHELL;
+    try {
+      const { buildShellLaunchArgs } = await importShared();
+      const cmd = 'opencode attach url';
+      expect(buildShellLaunchArgs(cmd)).toEqual(['/bin/sh', '-c', cmd]);
+    } finally {
+      process.env.SHELL = original;
+    }
+  });
+});

+ 66 - 0
src/multiplexer/shared.ts

@@ -39,6 +39,68 @@ export function buildOpencodeAttachCommand(
   ].join(' ');
 }
 
+/**
+ * Resolve the absolute path to the running OpenCode binary so child shells
+ * (e.g. a kitty-launched window) don't need `opencode` on their PATH. Falls
+ * back to the bare `opencode` name when no absolute path can be determined.
+ */
+export function resolveOpencodeExecutable(): string {
+  return resolveHostOpencodeBinary() ?? 'opencode';
+}
+
+/**
+ * Build the `[shell, ...shellArgs, command]` array for launching a command in
+ * the user's interactive shell. OpenCode respects the user's shell when running
+ * commands; multiplexer panes must do the same, otherwise a hardcoded `sh -c`
+ * breaks under non-POSIX shells (fish, nu, powershell, ...) or misses login
+ * startup files (where `opencode` may be on PATH).
+ *
+ * Mirrors OpenCode's own `Shell.args()` resolution:
+ * - nu / fish: `<shell> -c <command>` (no login mode)
+ * - zsh: login mode, sources zshenv/zshrc, then runs command
+ * - bash: login mode, sources bashrc, then runs command
+ * - cmd: `cmd /c <command>`
+ * - powershell: `pwsh -NoProfile -Command <command>`
+ * - default (sh/dash/elvish/xonsh/...): `<shell> -c <command>`
+ *
+ * Note: the working directory is supplied by the launcher (e.g. kitty's
+ * `--cwd`), not by a `cd` inside the shell command.
+ */
+export function buildShellLaunchArgs(command: string): string[] {
+  const shell = process.env.SHELL || '/bin/sh';
+  const name = (shell.split(/[/\\]/).at(-1) ?? 'sh').replace(
+    /\.(exe|EXE)$/,
+    '',
+  );
+
+  if (name === 'nu' || name === 'fish') {
+    return [shell, '-c', command];
+  }
+  if (name === 'zsh') {
+    return [
+      shell,
+      '-l',
+      '-c',
+      `[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true\n[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1\n${command}`,
+    ];
+  }
+  if (name === 'bash') {
+    return [
+      shell,
+      '-l',
+      '-c',
+      `shopt -s expand_aliases\n[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true\n${command}`,
+    ];
+  }
+  if (name === 'cmd') {
+    return [shell, '/c', command];
+  }
+  if (name === 'pwsh' || name === 'powershell') {
+    return [shell, '-NoProfile', '-Command', command];
+  }
+  return [shell, '-c', command];
+}
+
 export function resolveHostOpencodeBinary(
   options: {
     override?: string;
@@ -140,6 +202,8 @@ export interface GracefulClosePaneOptions {
   acceptExitCode1?: boolean;
   /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
   emptyPaneReturnsTrue?: boolean;
+  /** Env to pass to the kitten/kitty invocations (e.g. KITTY_LISTEN_ON). */
+  env?: Record<string, string | undefined>;
 }
 
 export async function gracefulClosePane(
@@ -156,6 +220,7 @@ export async function gracefulClosePane(
     const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
       stdout: 'ignore',
       stderr: 'ignore',
+      env: options.env,
     });
     await ctrlCProc.exited;
 
@@ -164,6 +229,7 @@ export async function gracefulClosePane(
     const proc = crossSpawn([binary, ...options.close], {
       stdout: 'ignore',
       stderr: 'ignore',
+      env: options.env,
     });
     const exitCode = await proc.exited;
 

+ 2 - 2
src/multiplexer/types.ts

@@ -17,10 +17,10 @@ export interface PaneResult {
 /**
  * Core multiplexer interface
  * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer,
- * CmuxMultiplexer
+ * CmuxMultiplexer, KittyMultiplexer
  */
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux';
+  readonly type: 'tmux' | 'zellij' | 'herdr' | 'cmux' | 'kitty';
 
   /**
    * Check if the multiplexer binary is available on the system