Browse Source

Tmux integration part 1

* tmux-1

* tmux-2

* tmux-layouts

* getting ready tmux

* readme
Alvin 2 months ago
parent
commit
c985b9af80

+ 1 - 0
.gitignore

@@ -35,3 +35,4 @@ coverage/
 # Temporary files
 tmp/
 temp/
+local

+ 138 - 22
README.md

@@ -16,6 +16,33 @@
 
 ---
 
+## ⚡ Quick Navigation
+
+- [🚀 **Installation**](#installation)
+  - [For Humans](#for-humans)
+  - [For LLM Agents](#for-llm-agents)
+- [🏗️ **Architecture & Flow**](#architecture--flow)
+- [🏛️ **Meet the Pantheon**](#meet-the-pantheon)
+  - [The Orchestrator](#the-orchestrator---architect-of-realms)
+  - [The Explorer](#the-explorer---pathfinder)
+  - [The Oracle](#the-oracle---seer-beyond-the-stack-trace)
+  - [The Librarian](#the-librarian---keeper-of-infinite-scrolls)
+  - [The Designer](#the-designer---artisan-of-interfaces)
+  - [The Scribe](#the-scribe---chronicle-keeper)
+  - [The Visionary](#the-visionary---reader-of-pixels)
+  - [The Minimalist](#the-minimalist---destroyer-of-bloat)
+- [🛠️ **Tools & Capabilities**](#tools--capabilities)
+  - [Tmux Integration](#tmux-integration)
+  - [Quota Tool](#quota-tool)
+  - [Background Tasks](#background-tasks)
+  - [LSP Tools](#lsp-tools)
+  - [Code Search Tools](#code-search-tools)
+- [🔌 **MCP Servers**](#mcp-servers)
+- [⚙️ **Configuration**](#configuration)
+- [🗑️ **Uninstallation**](#uninstallation)
+
+---
+
 ## Installation
 
 ### For Humans
@@ -100,23 +127,16 @@ The installer automatically:
 
 #### Step 4: Authentication
 
-After installation, guide the user:
+After installation, guide the user to log in for each enabled provider:
 
-**For Antigravity:**
 ```bash
 opencode auth login
-# Select: Google → OAuth with Google (Antigravity)
 ```
 
-**For OpenAI:**
-```bash
-export OPENAI_API_KEY="sk-..."
-```
-
-**For Cerebras:**
-```bash
-export CEREBRAS_API_KEY="..."
-```
+The user should select:
+- **For Antigravity**: Google → OAuth with Google (Antigravity)
+- **For OpenAI**: OpenAI → Enter API Key
+- **For Cerebras**: Cerebras → Enter API Key
 
 ---
 
@@ -283,8 +303,110 @@ Identify unnecessary complexity, challenge premature abstractions, estimate LOC
 
 ## Tools & Capabilities
 
+### Tmux Integration
+
+<img src="img/tmux.png" alt="Tmux Integration" width="800">
+
+**Watch your agents work in real-time.** When the Orchestrator launches sub-agents or initiates background tasks, new tmux panes automatically spawn showing each agent's live progress. No more waiting in the dark.
+
+#### Why This Matters
+
+| Without Tmux Integration | With Tmux Integration |
+|--------------------------|----------------------|
+| Fire off a background task, wait anxiously | See the agent thinking, searching, coding |
+| "Is it stuck or just slow?" | Watch tool calls happen in real-time |
+| Results appear out of nowhere | Follow the journey from question to answer |
+| Debug by guessing | Debug by observation |
+
+#### What You Get
+
+- **Live Visibility**: Each sub-agent gets its own pane showing real-time output
+- **Auto-Layout**: Tmux automatically arranges panes using your preferred layout
+- **Auto-Cleanup**: Panes close when agents finish, layout rebalances
+- **Zero Overhead**: Works with OpenCode's built-in `task` tool AND our `background_task` tool
+
+#### Quick Setup
+
+**1. Enable the OpenCode HTTP server** (one-time setup)
+
+Add to your `~/.config/opencode/opencode.json`:
+
+```json
+{
+  "server": {
+    "port": 4096
+  }
+}
+```
+
+**2. Enable tmux integration in the plugin**
+
+Add to your `~/.config/opencode/oh-my-opencode-slim.json`:
+
+```json
+{
+  "tmux": {
+    "enabled": true,
+    "layout": "main-vertical",
+    "main_pane_size": 60
+  }
+}
+```
+
+**3. Run OpenCode inside tmux**
+
+```bash
+tmux
+opencode
+```
+
+That's it. When agents spawn, they'll appear in new panes.
+
+#### Layout Options
+
+| Layout | Description |
+|--------|-------------|
+| `main-vertical` | Your session on the left (60%), agents stacked on the right |
+| `main-horizontal` | Your session on top (60%), agents stacked below |
+| `tiled` | All panes in equal-sized grid |
+| `even-horizontal` | All panes side by side |
+| `even-vertical` | All panes stacked vertically |
+
+#### Configuration Reference
+
+```json
+{
+  "tmux": {
+    "enabled": true,
+    "layout": "main-vertical",
+    "main_pane_size": 60
+  }
+}
+```
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enabled` | boolean | `false` | Enable/disable tmux integration |
+| `layout` | string | `"main-vertical"` | Tmux layout preset |
+| `main_pane_size` | number | `60` | Size of main pane as percentage (20-80) |
+
+---
+
+### Quota Tool
+
+For Antigravity users. You can trigger this at any time by asking the agent to **"check my quota"** or **"show status."**
+
+<img src="img/quota.png" alt="Antigravity Quota" width="600">
+
+| Tool | Description |
+|------|-------------|
+| `antigravity_quota` | Check API quota for all Antigravity accounts (compact view with progress bars) |
+
+---
+
 ### Background Tasks
 
+
 The plugin provides tools to manage asynchronous work:
 
 | Tool | Description |
@@ -293,6 +415,8 @@ The plugin provides tools to manage asynchronous work:
 | `background_output` | Fetch the result of a background task by ID |
 | `background_cancel` | Abort running tasks |
 
+---
+
 ### LSP Tools
 
 Language Server Protocol integration for code intelligence:
@@ -304,6 +428,8 @@ Language Server Protocol integration for code intelligence:
 | `lsp_diagnostics` | Get errors/warnings from the language server |
 | `lsp_rename` | Rename a symbol across all files |
 
+---
+
 ### Code Search Tools
 
 Fast code search and refactoring:
@@ -314,16 +440,6 @@ Fast code search and refactoring:
 | `ast_grep_search` | AST-aware code pattern matching (25 languages) |
 | `ast_grep_replace` | AST-aware code refactoring with dry-run support |
 
-### Quota Tool
-
-For Antigravity users:
-
-<img src="img/quota.png" alt="Antigravity Quota" width="600">
-
-| Tool | Description |
-|------|-------------|
-| `antigravity_quota` | Check API quota for all Antigravity accounts (compact view with progress bars) |
-
 ---
 
 ## MCP Servers

BIN
img/tmux.png


+ 1 - 0
src/config/loader.ts

@@ -71,6 +71,7 @@ export function loadPluginConfig(directory: string): PluginConfig {
       ...config,
       ...projectConfig,
       agents: deepMerge(config.agents, projectConfig.agents),
+      tmux: deepMerge(config.tmux, projectConfig.tmux),
       disabled_agents: [
         ...new Set([
           ...(config.disabled_agents ?? []),

+ 21 - 0
src/config/schema.ts

@@ -9,6 +9,26 @@ export const AgentOverrideConfigSchema = z.object({
   disable: z.boolean().optional(),
 });
 
+// Tmux layout options
+export const TmuxLayoutSchema = z.enum([
+  "main-horizontal", // Main pane on top, agents stacked below
+  "main-vertical",   // Main pane on left, agents stacked on right
+  "tiled",           // All panes equal size grid
+  "even-horizontal", // All panes side by side
+  "even-vertical",   // All panes stacked vertically
+]);
+
+export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>;
+
+// Tmux integration configuration
+export const TmuxConfigSchema = z.object({
+  enabled: z.boolean().default(false),
+  layout: TmuxLayoutSchema.default("main-vertical"),
+  main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
+});
+
+export type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
+
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 
 // MCP names
@@ -20,6 +40,7 @@ export const PluginConfigSchema = z.object({
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   disabled_agents: z.array(z.string()).optional(),
   disabled_mcps: z.array(z.string()).optional(),
+  tmux: TmuxConfigSchema.optional(),
 });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;

+ 47 - 21
src/features/background-manager.ts

@@ -1,5 +1,6 @@
 import type { PluginInput } from "@opencode-ai/plugin";
 import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from "../config";
+import type { TmuxConfig } from "../config/schema";
 
 type OpencodeClient = PluginInput["client"];
 
@@ -32,10 +33,12 @@ export class BackgroundTaskManager {
   private client: OpencodeClient;
   private directory: string;
   private pollInterval?: ReturnType<typeof setInterval>;
+  private tmuxEnabled: boolean;
 
-  constructor(ctx: PluginInput) {
+  constructor(ctx: PluginInput, tmuxConfig?: TmuxConfig) {
     this.client = ctx.client;
     this.directory = ctx.directory;
+    this.tmuxEnabled = tmuxConfig?.enabled ?? false;
   }
 
   async launch(opts: LaunchOptions): Promise<BackgroundTask> {
@@ -63,9 +66,14 @@ export class BackgroundTaskManager {
     this.tasks.set(task.id, task);
     this.startPolling();
 
+    // Give TmuxSessionManager time to spawn the pane via event hook
+    // before we send the prompt (so the TUI can receive streaming updates)
+    if (this.tmuxEnabled) {
+      await new Promise((r) => setTimeout(r, 500));
+    }
+
     const promptQuery: Record<string, string> = {
       directory: this.directory,
-      agent: opts.agent,
     };
     if (opts.model) {
       promptQuery.model = opts.model;
@@ -74,6 +82,8 @@ export class BackgroundTaskManager {
     await this.client.session.prompt({
       path: { id: session.data.id },
       body: {
+        agent: opts.agent,
+        tools: { background_task: false, task: false },
         parts: [{ type: "text", text: opts.prompt }],
       },
       query: promptQuery,
@@ -147,31 +157,47 @@ export class BackgroundTaskManager {
 
   private async pollTask(task: BackgroundTask) {
     try {
-      const session = await this.client.session.get({
-        path: { id: task.sessionId },
-      });
-
-      const sessionData = session.data as { share?: { messages?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> } } | undefined;
-      const messages = sessionData?.share?.messages ?? [];
-      const assistantMessages = messages.filter((m) => m.role === "assistant");
-      const lastMessage = assistantMessages[assistantMessages.length - 1];
-
-      if (lastMessage?.parts) {
-        const textContent = lastMessage.parts
-          .filter((p) => p.type === "text" && p.text)
-          .map((p) => p.text)
-          .join("\n");
-
-        if (textContent) {
-          task.result = textContent;
-          task.status = "completed";
-          task.completedAt = new Date();
+      // Check session status first
+      const statusResult = await this.client.session.status();
+      const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+      const sessionStatus = allStatuses[task.sessionId];
+
+      // If session is still active (not idle), don't try to read messages yet
+      if (sessionStatus && sessionStatus.type !== "idle") {
+        return;
+      }
+
+      // Get messages using correct API
+      const messagesResult = await this.client.session.messages({ path: { id: task.sessionId } });
+      const messages = (messagesResult.data ?? messagesResult) as Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>;
+      const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
+
+      if (assistantMessages.length === 0) {
+        return; // No response yet
+      }
+
+      // Extract text from all assistant messages
+      const extractedContent: string[] = [];
+      for (const message of assistantMessages) {
+        for (const part of message.parts ?? []) {
+          if ((part.type === "text" || part.type === "reasoning") && part.text) {
+            extractedContent.push(part.text);
+          }
         }
       }
+
+      const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
+      if (responseText) {
+        task.result = responseText;
+        task.status = "completed";
+        task.completedAt = new Date();
+        // Pane closing is handled by TmuxSessionManager via polling
+      }
     } catch (error) {
       task.status = "failed";
       task.error = error instanceof Error ? error.message : String(error);
       task.completedAt = new Date();
+      // Pane closing is handled by TmuxSessionManager via polling
     }
   }
 }

+ 1 - 0
src/features/index.ts

@@ -1 +1,2 @@
 export { BackgroundTaskManager, type BackgroundTask, type LaunchOptions } from "./background-manager";
+export { TmuxSessionManager } from "./tmux-session-manager";

+ 197 - 0
src/features/tmux-session-manager.ts

@@ -0,0 +1,197 @@
+import type { PluginInput } from "@opencode-ai/plugin";
+import { spawnTmuxPane, closeTmuxPane, isInsideTmux } from "../utils/tmux";
+import type { TmuxConfig } from "../config/schema";
+import { log } from "../shared/logger";
+
+type OpencodeClient = PluginInput["client"];
+
+interface TrackedSession {
+  sessionId: string;
+  paneId: string;
+  parentId: string;
+  title: string;
+  createdAt: number;
+}
+
+const POLL_INTERVAL_MS = 2000;
+const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
+
+/**
+ * TmuxSessionManager tracks child sessions (created by OpenCode's Task tool)
+ * and spawns/closes tmux panes for them.
+ */
+export class TmuxSessionManager {
+  private client: OpencodeClient;
+  private tmuxConfig: TmuxConfig;
+  private serverUrl: string;
+  private sessions = new Map<string, TrackedSession>();
+  private pollInterval?: ReturnType<typeof setInterval>;
+  private enabled = false;
+
+  constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
+    this.client = ctx.client;
+    this.tmuxConfig = tmuxConfig;
+    this.serverUrl = ctx.serverUrl?.toString() ?? "http://localhost:4096";
+    this.enabled = tmuxConfig.enabled && isInsideTmux();
+
+    log("[tmux-session-manager] initialized", {
+      enabled: this.enabled,
+      tmuxConfig: this.tmuxConfig,
+      serverUrl: this.serverUrl,
+    });
+  }
+
+  /**
+   * Handle session.created events.
+   * Spawns a tmux pane for child sessions (those with parentID).
+   */
+  async onSessionCreated(event: {
+    type: string;
+    properties?: { info?: { id?: string; parentID?: string; title?: string } };
+  }): Promise<void> {
+    if (!this.enabled) return;
+    if (event.type !== "session.created") return;
+
+    const info = event.properties?.info;
+    if (!info?.id || !info?.parentID) {
+      // Not a child session, skip
+      return;
+    }
+
+    const sessionId = info.id;
+    const parentId = info.parentID;
+    const title = info.title ?? "Subagent";
+
+    // Skip if we're already tracking this session
+    if (this.sessions.has(sessionId)) {
+      log("[tmux-session-manager] session already tracked", { sessionId });
+      return;
+    }
+
+    log("[tmux-session-manager] child session created, spawning pane", {
+      sessionId,
+      parentId,
+      title,
+    });
+
+    const paneResult = await spawnTmuxPane(
+      sessionId,
+      title,
+      this.tmuxConfig,
+      this.serverUrl
+    ).catch((err) => {
+      log("[tmux-session-manager] failed to spawn pane", { error: String(err) });
+      return { success: false, paneId: undefined };
+    });
+
+    if (paneResult.success && paneResult.paneId) {
+      this.sessions.set(sessionId, {
+        sessionId,
+        paneId: paneResult.paneId,
+        parentId,
+        title,
+        createdAt: Date.now(),
+      });
+
+      log("[tmux-session-manager] pane spawned", {
+        sessionId,
+        paneId: paneResult.paneId,
+      });
+
+      this.startPolling();
+    }
+  }
+
+  private startPolling(): void {
+    if (this.pollInterval) return;
+
+    this.pollInterval = setInterval(() => this.pollSessions(), POLL_INTERVAL_MS);
+    log("[tmux-session-manager] polling started");
+  }
+
+  private stopPolling(): void {
+    if (this.pollInterval) {
+      clearInterval(this.pollInterval);
+      this.pollInterval = undefined;
+      log("[tmux-session-manager] polling stopped");
+    }
+  }
+
+  private async pollSessions(): Promise<void> {
+    if (this.sessions.size === 0) {
+      this.stopPolling();
+      return;
+    }
+
+    try {
+      const statusResult = await this.client.session.status();
+      const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+
+      const now = Date.now();
+      const sessionsToClose: string[] = [];
+
+      for (const [sessionId, tracked] of this.sessions.entries()) {
+        const status = allStatuses[sessionId];
+
+        // Session is idle (completed) or not found (deleted)
+        const isIdle = !status || status.type === "idle";
+
+        // Check for timeout
+        const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
+
+        if (isIdle || isTimedOut) {
+          sessionsToClose.push(sessionId);
+        }
+      }
+
+      for (const sessionId of sessionsToClose) {
+        await this.closeSession(sessionId);
+      }
+    } catch (err) {
+      log("[tmux-session-manager] poll error", { error: String(err) });
+    }
+  }
+
+  private async closeSession(sessionId: string): Promise<void> {
+    const tracked = this.sessions.get(sessionId);
+    if (!tracked) return;
+
+    log("[tmux-session-manager] closing session pane", {
+      sessionId,
+      paneId: tracked.paneId,
+    });
+
+    await closeTmuxPane(tracked.paneId);
+    this.sessions.delete(sessionId);
+
+    if (this.sessions.size === 0) {
+      this.stopPolling();
+    }
+  }
+
+  /**
+   * Create the event handler for the plugin's event hook.
+   */
+  createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
+    return async (input) => {
+      await this.onSessionCreated(input.event as {
+        type: string;
+        properties?: { info?: { id?: string; parentID?: string; title?: string } };
+      });
+    };
+  }
+
+  /**
+   * Clean up all tracked sessions.
+   */
+  async cleanup(): Promise<void> {
+    this.stopPolling();
+
+    for (const tracked of this.sessions.values()) {
+      await closeTmuxPane(tracked.paneId);
+    }
+
+    this.sessions.clear();
+    log("[tmux-session-manager] cleanup complete");
+  }
+}

+ 36 - 5
src/index.ts

@@ -1,6 +1,6 @@
 import type { Plugin } from "@opencode-ai/plugin";
 import { getAgentConfigs } from "./agents";
-import { BackgroundTaskManager } from "./features";
+import { BackgroundTaskManager, TmuxSessionManager } from "./features";
 import {
   createBackgroundTools,
   lsp_goto_definition,
@@ -12,17 +12,41 @@ import {
   ast_grep_replace,
   antigravity_quota,
 } from "./tools";
-import { loadPluginConfig } from "./config";
+import { loadPluginConfig, type TmuxConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
 import { createAutoUpdateCheckerHook } from "./hooks";
+import { startTmuxCheck } from "./utils";
+import { log } from "./shared/logger";
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const config = loadPluginConfig(ctx.directory);
   const agents = getAgentConfigs(config);
-  const backgroundManager = new BackgroundTaskManager(ctx);
-  const backgroundTools = createBackgroundTools(ctx, backgroundManager);
+
+  // Parse tmux config with defaults
+  const tmuxConfig: TmuxConfig = {
+    enabled: config.tmux?.enabled ?? false,
+    layout: config.tmux?.layout ?? "main-vertical",
+    main_pane_size: config.tmux?.main_pane_size ?? 60,
+  };
+
+  log("[plugin] initialized with tmux config", { 
+    tmuxConfig, 
+    rawTmuxConfig: config.tmux,
+    directory: ctx.directory 
+  });
+
+  // Start background tmux check if enabled
+  if (tmuxConfig.enabled) {
+    startTmuxCheck();
+  }
+
+  const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig);
+  const backgroundTools = createBackgroundTools(ctx, backgroundManager, tmuxConfig);
   const mcps = createBuiltinMcps(config.disabled_mcps);
 
+  // Initialize TmuxSessionManager to handle OpenCode's built-in Task tool sessions
+  const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
+
   // Initialize auto-update checker hook
   const autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
     showStartupToast: true,
@@ -68,12 +92,19 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
 
     event: async (input) => {
+      // Handle auto-update checking
       await autoUpdateChecker.event(input);
+      
+      // Handle tmux pane spawning for OpenCode's Task tool sessions
+      await tmuxSessionManager.onSessionCreated(input.event as {
+        type: string;
+        properties?: { info?: { id?: string; parentID?: string; title?: string } };
+      });
     },
   };
 };
 
 export default OhMyOpenCodeLite;
 
-export type { PluginConfig, AgentOverrideConfig, AgentName, McpName } from "./config";
+export type { PluginConfig, AgentOverrideConfig, AgentName, McpName, TmuxConfig, TmuxLayout } from "./config";
 export type { RemoteMcpConfig } from "./mcp";

+ 12 - 2
src/tools/background.ts

@@ -7,6 +7,7 @@ import {
   DEFAULT_TIMEOUT_MS,
   STABLE_POLLS_THRESHOLD,
 } from "../config";
+import type { TmuxConfig } from "../config/schema";
 
 const z = tool.schema;
 
@@ -19,7 +20,8 @@ type ToolContext = {
 
 export function createBackgroundTools(
   ctx: PluginInput,
-  manager: BackgroundTaskManager
+  manager: BackgroundTaskManager,
+  tmuxConfig?: TmuxConfig
 ): Record<string, ToolDefinition> {
   const agentList = getAgentListDescription();
   const agentNames = getAgentNames().join(", ");
@@ -46,7 +48,7 @@ Sync mode blocks until completion and returns the result directly.`,
       const isSync = args.sync === true;
 
       if (isSync) {
-        return await executeSync(description, prompt, agent, tctx, ctx, args.session_id as string | undefined);
+        return await executeSync(description, prompt, agent, tctx, ctx, tmuxConfig, args.session_id as string | undefined);
       }
 
       const task = await manager.launch({
@@ -138,6 +140,7 @@ async function executeSync(
   agent: string,
   toolContext: ToolContext,
   ctx: PluginInput,
+  tmuxConfig?: TmuxConfig,
   existingSessionId?: string
 ): Promise<string> {
   let sessionID: string;
@@ -164,6 +167,12 @@ async function executeSync(
       return `Error: Failed to create session: ${createResult.error}`;
     }
     sessionID = createResult.data.id;
+
+    // Give TmuxSessionManager time to spawn the pane via event hook
+    // before we send the prompt (so the TUI can receive streaming updates)
+    if (tmuxConfig?.enabled) {
+      await new Promise((r) => setTimeout(r, 500));
+    }
   }
 
   // Disable recursive delegation tools to prevent infinite loops
@@ -257,6 +266,7 @@ session_id: ${sessionID}
 
   const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
 
+  // Pane closing is handled by TmuxSessionManager via polling
   return `${responseText}
 
 <task_metadata>

+ 1 - 0
src/utils/index.ts

@@ -1 +1,2 @@
 export * from "./polling";
+export * from "./tmux";

+ 322 - 0
src/utils/tmux.ts

@@ -0,0 +1,322 @@
+import { spawn } from "bun";
+import { log } from "../shared/logger";
+import type { TmuxConfig, TmuxLayout } from "../config/schema";
+
+let tmuxPath: string | null = null;
+let tmuxChecked = false;
+
+// Store config for reapplying layout on close
+let storedConfig: TmuxConfig | null = null;
+
+// Cache server availability check
+let serverAvailable: boolean | null = null;
+let serverCheckUrl: string | null = null;
+
+/**
+ * Check if the OpenCode HTTP server is actually running.
+ * This is needed because ctx.serverUrl may return a fallback URL even when no server is running.
+ */
+async function isServerRunning(serverUrl: string): Promise<boolean> {
+  // Use cached result if checking the same URL
+  if (serverCheckUrl === serverUrl && serverAvailable !== null) {
+    return serverAvailable;
+  }
+
+  try {
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 1000);
+
+    // Try to hit the health endpoint or just the root
+    const response = await fetch(`${serverUrl}/health`, {
+      signal: controller.signal,
+    }).catch(() => null);
+
+    clearTimeout(timeout);
+
+    serverCheckUrl = serverUrl;
+    serverAvailable = response?.ok ?? false;
+
+    log("[tmux] isServerRunning: checked", { serverUrl, available: serverAvailable });
+    return serverAvailable;
+  } catch {
+    serverCheckUrl = serverUrl;
+    serverAvailable = false;
+    log("[tmux] isServerRunning: server not reachable", { serverUrl });
+    return false;
+  }
+}
+
+/**
+ * Reset the server availability cache (useful when server might have started)
+ */
+export function resetServerCheck(): void {
+  serverAvailable = null;
+  serverCheckUrl = null;
+}
+
+/**
+ * Find tmux binary path
+ */
+async function findTmuxPath(): Promise<string | null> {
+  const isWindows = process.platform === "win32";
+  const cmd = isWindows ? "where" : "which";
+
+  try {
+    const proc = spawn([cmd, "tmux"], {
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+
+    const exitCode = await proc.exited;
+    if (exitCode !== 0) {
+      log("[tmux] findTmuxPath: 'which tmux' failed", { exitCode });
+      return null;
+    }
+
+    const stdout = await new Response(proc.stdout).text();
+    const path = stdout.trim().split("\n")[0];
+    if (!path) {
+      log("[tmux] findTmuxPath: no path in output");
+      return null;
+    }
+
+    // Verify it works
+    const verifyProc = spawn([path, "-V"], {
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+    const verifyExit = await verifyProc.exited;
+    if (verifyExit !== 0) {
+      log("[tmux] findTmuxPath: tmux -V failed", { path, verifyExit });
+      return null;
+    }
+
+    log("[tmux] findTmuxPath: found tmux", { path });
+    return path;
+  } catch (err) {
+    log("[tmux] findTmuxPath: exception", { error: String(err) });
+    return null;
+  }
+}
+
+/**
+ * Get cached tmux path, initializing if needed
+ */
+export async function getTmuxPath(): Promise<string | null> {
+  if (tmuxChecked) {
+    return tmuxPath;
+  }
+
+  tmuxPath = await findTmuxPath();
+  tmuxChecked = true;
+  log("[tmux] getTmuxPath: initialized", { tmuxPath });
+  return tmuxPath;
+}
+
+/**
+ * Check if we're running inside tmux
+ */
+export function isInsideTmux(): boolean {
+  return !!process.env.TMUX;
+}
+
+/**
+ * Apply a tmux layout to the current window
+ */
+async function applyLayout(tmux: string, layout: TmuxLayout, mainPaneSize: number): Promise<void> {
+  try {
+    // Apply the layout
+    const layoutProc = spawn([tmux, "select-layout", layout], {
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+    await layoutProc.exited;
+
+    // For main-* layouts, set the main pane size
+    if (layout === "main-horizontal" || layout === "main-vertical") {
+      const sizeOption = layout === "main-horizontal" 
+        ? "main-pane-height" 
+        : "main-pane-width";
+      
+      const sizeProc = spawn([tmux, "set-window-option", sizeOption, `${mainPaneSize}%`], {
+        stdout: "pipe",
+        stderr: "pipe",
+      });
+      await sizeProc.exited;
+
+      // Reapply layout to use the new size
+      const reapplyProc = spawn([tmux, "select-layout", layout], {
+        stdout: "pipe",
+        stderr: "pipe",
+      });
+      await reapplyProc.exited;
+    }
+
+    log("[tmux] applyLayout: applied", { layout, mainPaneSize });
+  } catch (err) {
+    log("[tmux] applyLayout: exception", { error: String(err) });
+  }
+}
+
+export interface SpawnPaneResult {
+  success: boolean;
+  paneId?: string; // e.g., "%42"
+}
+
+/**
+ * Spawn a new tmux pane running `opencode attach <serverUrl> --session <sessionId>`
+ * This connects the new TUI to the existing server so it receives streaming updates.
+ * After spawning, applies the configured layout to auto-rebalance all panes.
+ * Returns the pane ID so it can be closed later.
+ */
+export async function spawnTmuxPane(
+  sessionId: string,
+  description: string,
+  config: TmuxConfig,
+  serverUrl: string
+): Promise<SpawnPaneResult> {
+  log("[tmux] spawnTmuxPane called", { sessionId, description, config, serverUrl });
+
+  if (!config.enabled) {
+    log("[tmux] spawnTmuxPane: config.enabled is false, skipping");
+    return { success: false };
+  }
+
+  if (!isInsideTmux()) {
+    log("[tmux] spawnTmuxPane: not inside tmux, skipping");
+    return { success: false };
+  }
+
+  // Check if the OpenCode HTTP server is actually running
+  // This is needed because serverUrl may be a fallback even when no server is running
+  const serverRunning = await isServerRunning(serverUrl);
+  if (!serverRunning) {
+    log("[tmux] spawnTmuxPane: OpenCode server not running, skipping", { 
+      serverUrl,
+      hint: "Add { \"server\": { \"port\": 4096 } } to your OpenCode config"
+    });
+    return { success: false };
+  }
+
+  const tmux = await getTmuxPath();
+  if (!tmux) {
+    log("[tmux] spawnTmuxPane: tmux binary not found, skipping");
+    return { success: false };
+  }
+
+  // Store config for use in closeTmuxPane
+  storedConfig = config;
+
+  try {
+    // Use `opencode attach <url> --session <id>` to connect to the existing server
+    // This ensures the TUI receives streaming updates from the same server handling the prompt
+    const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+
+    // Simple split - layout will handle positioning
+    // Use -h for horizontal split (new pane to the right) as default
+    const args = [
+      "split-window",
+      "-h",
+      "-d", // Don't switch focus to new pane
+      "-P", // Print pane info
+      "-F", "#{pane_id}", // Format: just the pane ID
+      opencodeCmd,
+    ];
+
+    log("[tmux] spawnTmuxPane: executing", { tmux, args, opencodeCmd });
+
+    const proc = spawn([tmux, ...args], {
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+
+    const exitCode = await proc.exited;
+    const stdout = await new Response(proc.stdout).text();
+    const stderr = await new Response(proc.stderr).text();
+    const paneId = stdout.trim(); // e.g., "%42"
+
+    log("[tmux] spawnTmuxPane: split result", { exitCode, paneId, stderr: stderr.trim() });
+
+    if (exitCode === 0 && paneId) {
+      // Rename the pane for visibility
+      const renameProc = spawn(
+        [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
+        { stdout: "ignore", stderr: "ignore" }
+      );
+      await renameProc.exited;
+
+      // Apply layout to auto-rebalance all panes
+      const layout = config.layout ?? "main-vertical";
+      const mainPaneSize = config.main_pane_size ?? 60;
+      await applyLayout(tmux, layout, mainPaneSize);
+
+      log("[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied", { paneId, layout });
+      return { success: true, paneId };
+    }
+
+    return { success: false };
+  } catch (err) {
+    log("[tmux] spawnTmuxPane: exception", { error: String(err) });
+    return { success: false };
+  }
+}
+
+/**
+ * Close a tmux pane by its ID and reapply layout to rebalance remaining panes
+ */
+export async function closeTmuxPane(paneId: string): Promise<boolean> {
+  log("[tmux] closeTmuxPane called", { paneId });
+
+  if (!paneId) {
+    log("[tmux] closeTmuxPane: no paneId provided");
+    return false;
+  }
+
+  const tmux = await getTmuxPath();
+  if (!tmux) {
+    log("[tmux] closeTmuxPane: tmux binary not found");
+    return false;
+  }
+
+  try {
+    const proc = spawn([tmux, "kill-pane", "-t", paneId], {
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+
+    const exitCode = await proc.exited;
+    const stderr = await new Response(proc.stderr).text();
+
+    log("[tmux] closeTmuxPane: result", { exitCode, stderr: stderr.trim() });
+
+    if (exitCode === 0) {
+      log("[tmux] closeTmuxPane: SUCCESS, pane closed", { paneId });
+
+      // Reapply layout to rebalance remaining panes
+      if (storedConfig) {
+        const layout = storedConfig.layout ?? "main-vertical";
+        const mainPaneSize = storedConfig.main_pane_size ?? 60;
+        await applyLayout(tmux, layout, mainPaneSize);
+        log("[tmux] closeTmuxPane: layout reapplied", { layout });
+      }
+
+      return true;
+    }
+
+    // Pane might already be closed (user closed it manually, or process exited)
+    log("[tmux] closeTmuxPane: failed (pane may already be closed)", { paneId });
+    return false;
+  } catch (err) {
+    log("[tmux] closeTmuxPane: exception", { error: String(err) });
+    return false;
+  }
+}
+
+/**
+ * Start background check for tmux availability
+ */
+export function startTmuxCheck(): void {
+  if (!tmuxChecked) {
+    getTmuxPath().catch(() => {});
+  }
+}