Prechádzať zdrojové kódy

fix(tmux): route panes by attached parent session

Static TMUX_PANE capture in the shared server process sent every child pane to the server's original TUI. Register each local TUI's active session and pane, forward parent session identity during spawn, and retain the startup pane as a bounded fallback.
Erman HAVUÇ 4 týždňov pred
rodič
commit
ae9d8f623e

+ 13 - 0
docs/multiplexer-integration.md

@@ -326,6 +326,19 @@ OpenCode pane (resolved from the parent pane's `ZELLIJ_PANE_ID` via
 If the parent pane cannot be resolved, the tab target is omitted and Zellij
 places the pane in whatever tab it has focused — no tab id is guessed.
 
+### Tmux attached-session targeting
+
+When multiple local TUI clients attach to one OpenCode server from different
+tmux sessions, each TUI records its active OpenCode session and `TMUX_PANE`.
+Child panes and layout updates target the tmux pane registered by their parent
+session, so each attached root session keeps its subagents beside itself.
+
+Registrations are session-scoped, refreshed while the TUI is active, and expire
+after 30 seconds without a heartbeat. If no fresh registration exists,
+or tmux rejects a registered target, pane creation falls back to the server
+process's original `TMUX_PANE`. This preserves direct/local TUI behavior and
+avoids losing subagent visibility after an attached pane closes unexpectedly.
+
 ### Zellij details
 
 The Zellij adapter requires **Zellij 0.44.1 or newer**. Older releases are

+ 4 - 2
src/codemap.md

@@ -63,11 +63,13 @@ OpenCode Core → Plugin Initialization (index.ts)
 3. **Config Validation**: Checks if current directory has valid plugin config
 4. **Snapshot Loading**: Reads agent models/variants from `tui-state.ts`
 5. **Live Updates**: Sets up interval to refresh snapshot every 1000ms
-6. **Sidebar Rendering**: Renders sidebar with:
+6. **Tmux registration**: Refreshes the active session-to-`TMUX_PANE`
+   registration for parent-aware child-pane routing
+7. **Sidebar Rendering**: Renders sidebar with:
    - Plugin header (OMO-Slim + version)
    - Config status warning (if invalid)
    - Agent list with model/variant details
-7. **Lifecycle Management**: Cleans up interval on dispose
+8. **Lifecycle Management**: Cleans up interval and owned tmux registration on dispose
 
 ### State Persistence Flow (tui-state.ts)
 

+ 10 - 1
src/multiplexer/codemap.md

@@ -18,6 +18,9 @@ manage, and close panes for child OpenCode agent sessions.
   - `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.
+- **Tmux pane registry** (`tmux-pane-registry.ts`): Session-scoped,
+  expiring TUI pane registrations used to route child panes to the attached
+  root session that created them.
 - **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,
@@ -70,7 +73,7 @@ The session manager reacts to OpenCode session events:
    ├─ Validates event properties (sessionId, parentId)
    ├─ Checks if session is already tracked or spawning
    ├─ Records session in knownSessions
-   ├─ Spawns pane via multiplexer.spawnPane()
+   ├─ Spawns pane via multiplexer.spawnPane(), forwarding the parent session
    │  ├─ Validates server is running
    │  ├─ Creates new pane with:
    │  │  ├─ Command: opencode attach --session-id <sessionId>
@@ -151,6 +154,11 @@ interface MultiplexerConfig {
 ### Tmux Implementation
 
 - Uses `tmux` CLI commands via `spawn()` utility
+- Resolves a fresh parent-session pane registration before splitting and
+  falls back to the server process's startup pane if registration is absent or
+  rejected by tmux
+- Debounces layout updates per parent pane so attached tmux sessions remain
+  isolated during concurrent child creation
 - Creates panes with descriptive titles and working directories
 - Applies layouts using `tmux select-layout` and `tmux resize-pane`
 - Graceful shutdown: sends Ctrl+C before killing pane to allow clean process termination
@@ -182,6 +190,7 @@ interface MultiplexerConfig {
 | `index.ts` | Public API exports |
 | `types.ts` | Core interfaces and shared utilities |
 | `shared.ts` | Shared infrastructure (quoteShellArg, buildOpencodeAttachCommand, findBinary) |
+| `tmux-pane-registry.ts` | Attached TUI session-to-pane registration storage |
 | `factory.ts` | Multiplexer instance creation |
 | `session-manager.ts` | Session lifecycle management |
 | `tmux/index.ts` | tmux-specific implementation |

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

@@ -152,6 +152,7 @@ describe('MultiplexerSessionManager', () => {
         'Test Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-456' },
       );
     });
 
@@ -199,6 +200,7 @@ describe('MultiplexerSessionManager', () => {
         'Nested Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/child/directory',
+        { parentSessionId: 'parent-456' },
       );
     });
 
@@ -289,6 +291,7 @@ describe('MultiplexerSessionManager', () => {
         'Ready Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-ready' },
       );
     });
 
@@ -360,6 +363,7 @@ describe('MultiplexerSessionManager', () => {
         'Recover Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-recover-timeout' },
       );
     });
 
@@ -410,6 +414,7 @@ describe('MultiplexerSessionManager', () => {
         'Busy During Wait',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-busy-during-wait' },
       );
     });
 
@@ -467,6 +472,7 @@ describe('MultiplexerSessionManager', () => {
         'Respawn Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-respawn' },
       );
     });
 
@@ -768,6 +774,7 @@ describe('MultiplexerSessionManager', () => {
         'Resumed Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/resumed/dir',
+        { parentSessionId: 'parent' },
       );
     });
 
@@ -1694,6 +1701,7 @@ describe('MultiplexerSessionManager', () => {
         'Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/task/dir',
+        { parentSessionId: 'parent-789' },
       );
       expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
       expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
@@ -1761,6 +1769,7 @@ describe('MultiplexerSessionManager', () => {
         'Worker',
         `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
         '/test/directory',
+        { parentSessionId: 'parent-close-race' },
       );
     });
 

+ 18 - 2
src/multiplexer/session-manager.ts

@@ -346,7 +346,15 @@ export class MultiplexerSessionManager {
         return;
 
       const paneResult = await this.multiplexer
-        .spawnPane(sessionId, title, serverUrl, directory)
+        .spawnPane(
+          sessionId,
+          title,
+          serverUrl,
+          directory,
+          this.multiplexer.type === 'tmux'
+            ? { parentSessionId: parentId }
+            : undefined,
+        )
         .catch((err) => {
           log('[multiplexer-session-manager] failed to spawn pane', {
             instanceId: this.instanceId,
@@ -940,7 +948,15 @@ export class MultiplexerSessionManager {
         return;
 
       const paneResult = await this.multiplexer
-        .spawnPane(sessionId, known.title, serverUrl, known.directory)
+        .spawnPane(
+          sessionId,
+          known.title,
+          serverUrl,
+          known.directory,
+          this.multiplexer.type === 'tmux'
+            ? { parentSessionId: known.parentId }
+            : undefined,
+        )
         .catch((err) => {
           log('[multiplexer-session-manager] failed to respawn pane', {
             instanceId: this.instanceId,

+ 64 - 0
src/multiplexer/tmux-pane-registry.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  getTmuxPaneRegistrationPath,
+  readTmuxPane,
+  recordTmuxPane,
+  removeTmuxPane,
+  TMUX_PANE_REGISTRATION_TTL_MS,
+} from './tmux-pane-registry';
+
+describe('tmux pane registry', () => {
+  const originalXdgDataHome = process.env.XDG_DATA_HOME;
+  let stateDirectory: string;
+
+  beforeEach(() => {
+    stateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tmux-state-'));
+    process.env.XDG_DATA_HOME = stateDirectory;
+  });
+
+  afterEach(() => {
+    fs.rmSync(stateDirectory, { recursive: true, force: true });
+    if (originalXdgDataHome === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdgDataHome;
+    }
+  });
+
+  test('resolves a fresh pane registration for one session', () => {
+    expect(recordTmuxPane('root-a', '%42', 100)).toBe(true);
+
+    expect(readTmuxPane('root-a')).toBe('%42');
+    expect(readTmuxPane('root-b')).toBeUndefined();
+  });
+
+  test('ignores expired registrations', () => {
+    recordTmuxPane('root', '%42', 100);
+    const filePath = getTmuxPaneRegistrationPath('root');
+    const registration = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+
+    expect(
+      readTmuxPane(
+        'root',
+        registration.updatedAt + TMUX_PANE_REGISTRATION_TTL_MS + 1,
+      ),
+    ).toBeUndefined();
+  });
+
+  test('only removes a registration still owned by the disposing TUI', () => {
+    recordTmuxPane('root', '%42', 100);
+    removeTmuxPane('root', '%42', 200);
+    expect(readTmuxPane('root')).toBe('%42');
+
+    removeTmuxPane('root', '%42', 100);
+    expect(readTmuxPane('root')).toBeUndefined();
+  });
+
+  test('rejects invalid tmux pane identifiers', () => {
+    expect(recordTmuxPane('root', '../pane', 100)).toBe(false);
+    expect(readTmuxPane('root')).toBeUndefined();
+  });
+});

+ 115 - 0
src/multiplexer/tmux-pane-registry.ts

@@ -0,0 +1,115 @@
+import { createHash, randomUUID } from 'node:crypto';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+
+interface TmuxPaneRegistration {
+  version: 1;
+  sessionId: string;
+  paneId: string;
+  ownerPid: number;
+  updatedAt: number;
+}
+
+export const TMUX_PANE_REGISTRATION_TTL_MS = 30_000;
+
+function dataDir(): string {
+  return (
+    process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share')
+  );
+}
+
+function sessionScope(sessionId: string): string {
+  return createHash('sha256').update(sessionId).digest('hex').slice(0, 24);
+}
+
+export function getTmuxPaneRegistrationPath(sessionId: string): string {
+  return path.join(
+    dataDir(),
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'tmux-panes',
+    `${sessionScope(sessionId)}.json`,
+  );
+}
+
+function isPaneId(value: unknown): value is string {
+  return typeof value === 'string' && /^%\d+$/.test(value);
+}
+
+export function recordTmuxPane(
+  sessionId: string,
+  paneId: string,
+  ownerPid = process.pid,
+): boolean {
+  if (!sessionId || !isPaneId(paneId)) return false;
+
+  const registration: TmuxPaneRegistration = {
+    version: 1,
+    sessionId,
+    paneId,
+    ownerPid,
+    updatedAt: Date.now(),
+  };
+
+  try {
+    const filePath = getTmuxPaneRegistrationPath(sessionId);
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
+    try {
+      fs.writeFileSync(tmpPath, `${JSON.stringify(registration)}\n`);
+      fs.renameSync(tmpPath, filePath);
+      return true;
+    } finally {
+      try {
+        if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
+      } catch {
+        // Best-effort state cleanup.
+      }
+    }
+  } catch {
+    return false;
+  }
+}
+
+export function readTmuxPane(
+  sessionId: string,
+  now = Date.now(),
+): string | undefined {
+  try {
+    const parsed = JSON.parse(
+      fs.readFileSync(getTmuxPaneRegistrationPath(sessionId), 'utf8'),
+    ) as Partial<TmuxPaneRegistration>;
+    if (
+      parsed.version !== 1 ||
+      parsed.sessionId !== sessionId ||
+      !isPaneId(parsed.paneId) ||
+      typeof parsed.updatedAt !== 'number' ||
+      now - parsed.updatedAt > TMUX_PANE_REGISTRATION_TTL_MS
+    ) {
+      return undefined;
+    }
+    return parsed.paneId;
+  } catch {
+    return undefined;
+  }
+}
+
+export function removeTmuxPane(
+  sessionId: string,
+  paneId: string,
+  ownerPid = process.pid,
+): void {
+  try {
+    const filePath = getTmuxPaneRegistrationPath(sessionId);
+    const parsed = JSON.parse(
+      fs.readFileSync(filePath, 'utf8'),
+    ) as Partial<TmuxPaneRegistration>;
+    if (parsed.paneId === paneId && parsed.ownerPid === ownerPid) {
+      fs.unlinkSync(filePath);
+    }
+  } catch {
+    // Registration may already be gone or replaced by another TUI.
+  }
+}

+ 10 - 4
src/multiplexer/tmux/codemap.md

@@ -13,6 +13,8 @@ Implements the `Multiplexer` interface contract defined in `src/multiplexer/type
 - **Layout strategy**: Implements debounced layout application to prevent rapid successive layout changes during bursts of pane operations
 - **Graceful shutdown protocol**: Sends Ctrl+C signal before pane termination to allow child processes to exit cleanly
 - **Pane lifecycle hooks**: Triggers layout rebalancing after pane creation and destruction events
+- **Attached-session targeting**: Resolves the parent OpenCode session through
+  the session-scoped tmux pane registry, with startup-pane fallback
 
 ### Core Abstractions
 
@@ -33,12 +35,14 @@ Implements the `Multiplexer` interface contract defined in `src/multiplexer/type
 2. spawnPane(sessionId, description, serverUrl, directory)
    ├─ Validates tmux binary availability
    ├─ Constructs opencode attach command with quoted arguments
-   ├─ Executes: tmux split-window -h -d -P -F '#{pane_id}' <opencode-cmd>
+   ├─ Resolves parent session registration (or startup pane fallback)
+   ├─ Executes: tmux split-window -h -d -P -F '#{pane_id}' -t <target> <opencode-cmd>
+   ├─ Retries against the startup pane if a registered target is rejected
    ├─ Captures stdout to extract pane_id
    ├─ Renames pane with description (truncated to 30 chars)
    └─ Schedules layout rebalance via scheduleLayout()
 
-3. scheduleLayout() → applyLayout() (debounced 150ms)
+3. scheduleLayout(targetPane) → applyLayout() (debounced 150ms per target)
    ├─ Increments layoutGeneration counter
    ├─ Applies stored layout via tmux select-layout
    ├─ For main-* layouts: sets main-pane-width/height percentage
@@ -119,7 +123,8 @@ Implements the `Multiplexer` interface contract defined in `src/multiplexer/type
 
 - **Layout type**: Default 'main-vertical' via constructor parameter
 - **Main pane size**: Default 60% via constructor parameter
-- **Target pane**: Optional TMUX_PANE environment variable for nested operations
+- **Target pane**: Fresh parent-session registration when available; optional
+  startup `TMUX_PANE` fallback for direct/local TUI usage
 
 ### User Configuration
 
@@ -131,6 +136,7 @@ No user-facing configuration required. Tmux binary location and session environm
 |----------|----------|-----------|
 | tmux binary not found | Returns success: false, logs warning | Fallback to other multiplexer or graceful degradation |
 | Pane spawn fails | Returns success: false, logs error | Session continues without pane |
+| Registered parent pane is stale | Retries the split against startup pane | Direct/local behavior remains available |
 | Layout application fails | Silently ignored, logs debug | Maintains previous layout |
 | Pane already closed | Returns false, logs info | Idempotent operation |
 | Ctrl+C send fails | Proceeds to kill-pane | Ensures pane termination |
@@ -141,4 +147,4 @@ No user-facing configuration required. Tmux binary location and session environm
 - `src/multiplexer/config/schema.ts` - Layout type definitions
 - `src/multiplexer/multiplexer-manager.ts` - Session management integration
 - `src/utils/compat.ts` - Cross-platform process execution
-- `src/utils/logger.ts` - Logging infrastructure
+- `src/utils/logger.ts` - Logging infrastructure

+ 79 - 0
src/multiplexer/tmux/index.test.ts

@@ -1,4 +1,8 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { recordTmuxPane } from '../tmux-pane-registry';
 
 type SpawnResult = {
   exited: Promise<number>;
@@ -52,8 +56,12 @@ function commands(): string[][] {
 describe('TmuxMultiplexer', () => {
   const originalTmux = process.env.TMUX;
   const originalTmuxPane = process.env.TMUX_PANE;
+  const originalXdgDataHome = process.env.XDG_DATA_HOME;
+  let stateDirectory: string;
 
   beforeEach(() => {
+    stateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tmux-'));
+    process.env.XDG_DATA_HOME = stateDirectory;
     process.env.TMUX = '/tmp/tmux-test/default,1,0';
     process.env.TMUX_PANE = '%1';
 
@@ -71,10 +79,81 @@ describe('TmuxMultiplexer', () => {
   });
 
   afterEach(() => {
+    fs.rmSync(stateDirectory, { recursive: true, force: true });
+    if (originalXdgDataHome === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdgDataHome;
+    }
     process.env.TMUX = originalTmux;
     process.env.TMUX_PANE = originalTmuxPane;
   });
 
+  test('targets the pane registered by the attached parent session', async () => {
+    recordTmuxPane('root-session-b', '%42');
+    const { TmuxMultiplexer } = await importFreshTmux();
+    const tmux = new TmuxMultiplexer('main-vertical', 60);
+
+    await tmux.spawnPane(
+      'child-session',
+      'Attached worker',
+      'http://localhost:4096',
+      '/repo',
+      { parentSessionId: 'root-session-b' },
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split-window'),
+    );
+    expect(splitCommand).toContain('%42');
+    expect(splitCommand).not.toContain('%1');
+
+    await wait(300);
+    const layoutCommands = commands().filter((command) =>
+      command.includes('select-layout'),
+    );
+    expect(layoutCommands).toHaveLength(2);
+    expect(layoutCommands.every((command) => command.includes('%42'))).toBe(
+      true,
+    );
+  });
+
+  test('falls back to the server pane when a registered target rejects the split', async () => {
+    recordTmuxPane('root-session-b', '%42');
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which')
+        return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
+      if (command[1] === 'split-window' && command.includes('%42')) {
+        return createSpawnResult(1, '', 'target pane not found');
+      }
+      if (command[1] === 'split-window') {
+        return createSpawnResult(0, '%2\n');
+      }
+      return createSpawnResult();
+    });
+    const { TmuxMultiplexer } = await importFreshTmux();
+    const tmux = new TmuxMultiplexer('main-vertical', 60);
+
+    const result = await tmux.spawnPane(
+      'child-session',
+      'Attached worker',
+      'http://localhost:4096',
+      '/repo',
+      { parentSessionId: 'root-session-b' },
+    );
+
+    const splitCommands = commands().filter((command) =>
+      command.includes('split-window'),
+    );
+    expect(result).toEqual({ success: true, paneId: '%2' });
+    expect(splitCommands).toHaveLength(2);
+    expect(splitCommands[0]).toContain('%42');
+    expect(splitCommands[1]).toContain('%1');
+
+    await tmux.applyLayout('tiled', 60);
+  });
+
   test('coalesces layout application after bursty pane spawns', async () => {
     const { TmuxMultiplexer } = await importFreshTmux();
     const tmux = new TmuxMultiplexer('main-vertical', 60);

+ 87 - 52
src/multiplexer/tmux/index.ts

@@ -10,7 +10,8 @@ import {
   findBinary,
   gracefulClosePane,
 } from '../shared';
-import type { Multiplexer, PaneResult } from '../types';
+import { readTmuxPane } from '../tmux-pane-registry';
+import type { Multiplexer, PaneResult, PaneSpawnOptions } from '../types';
 
 const TMUX_LAYOUT_DEBOUNCE_MS = 150;
 
@@ -22,8 +23,8 @@ export class TmuxMultiplexer implements Multiplexer {
   private storedLayout: MultiplexerLayout;
   private storedMainPaneSize: number;
   private targetPane = process.env.TMUX_PANE;
-  private layoutTimer?: ReturnType<typeof setTimeout>;
-  private layoutGeneration = 0;
+  private layoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
+  private paneTargets = new Map<string, string | undefined>();
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     this.storedLayout = layout;
@@ -49,6 +50,7 @@ export class TmuxMultiplexer implements Multiplexer {
     description: string,
     serverUrl: string,
     directory: string,
+    options?: PaneSpawnOptions,
   ): Promise<PaneResult> {
     const tmux = await this.getBinary();
     if (!tmux) {
@@ -64,37 +66,35 @@ export class TmuxMultiplexer implements Multiplexer {
         directory,
       );
 
-      // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
-      const args = [
-        'split-window',
-        '-h', // Horizontal split (pane to the right)
-        '-d', // Don't switch focus
-        '-P', // Print pane info
-        '-F',
-        '#{pane_id}', // Format: just the pane ID
-        ...this.targetArgs(),
-        opencodeCmd,
-      ];
-
-      log('[tmux] spawnPane: executing', { tmux, args });
-
-      const proc = crossSpawn([tmux, ...args], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
+      const registeredTarget = options?.parentSessionId
+        ? readTmuxPane(options.parentSessionId)
+        : undefined;
+      let targetPane = registeredTarget ?? this.targetPane;
+      let result = await this.splitPane(tmux, targetPane, opencodeCmd);
+
+      if (
+        result.exitCode !== 0 &&
+        registeredTarget &&
+        this.targetPane !== registeredTarget
+      ) {
+        log('[tmux] spawnPane: registered target failed, using fallback', {
+          registeredTarget,
+          fallbackTarget: this.targetPane,
+        });
+        targetPane = this.targetPane;
+        result = await this.splitPane(tmux, targetPane, opencodeCmd);
+      }
 
-      const exitCode = await proc.exited;
-      const stdout = await proc.stdout();
-      const stderr = await proc.stderr();
-      const paneId = stdout.trim();
+      const paneId = result.stdout.trim();
 
       log('[tmux] spawnPane: result', {
-        exitCode,
+        exitCode: result.exitCode,
         paneId,
-        stderr: stderr.trim(),
+        stderr: result.stderr.trim(),
+        targetPane,
       });
 
-      if (exitCode === 0 && paneId) {
+      if (result.exitCode === 0 && paneId) {
         // Rename the pane for visibility
         const renameProc = crossSpawn(
           [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
@@ -103,7 +103,8 @@ export class TmuxMultiplexer implements Multiplexer {
         await renameProc.exited;
 
         // Rebalance panes after bursts of child sessions settle.
-        this.scheduleLayout();
+        this.paneTargets.set(paneId, targetPane);
+        this.scheduleLayout(targetPane);
 
         log('[tmux] spawnPane: SUCCESS', { paneId });
         return { success: true, paneId };
@@ -118,11 +119,15 @@ export class TmuxMultiplexer implements Multiplexer {
 
   async closePane(paneId: string): Promise<boolean> {
     const tmux = await this.getBinary();
+    const layoutTarget = this.paneTargets.get(paneId) ?? this.targetPane;
     const closed = await gracefulClosePane(tmux, paneId, {
       ctrlC: ['send-keys', '-t', paneId, 'C-c'],
       close: ['kill-pane', '-t', paneId],
     });
-    if (closed) this.scheduleLayout();
+    if (closed) {
+      this.paneTargets.delete(paneId);
+      this.scheduleLayout(layoutTarget);
+    }
     return closed;
   }
 
@@ -130,31 +135,32 @@ export class TmuxMultiplexer implements Multiplexer {
     layout: MultiplexerLayout,
     mainPaneSize: number,
   ): Promise<void> {
-    if (this.layoutTimer) {
-      clearTimeout(this.layoutTimer);
-      this.layoutTimer = undefined;
-    }
-
-    this.layoutGeneration++;
-    await this.applyLayoutNow(layout, mainPaneSize);
+    for (const timer of this.layoutTimers.values()) clearTimeout(timer);
+    this.layoutTimers.clear();
+    await this.applyLayoutNow(layout, mainPaneSize, this.targetPane);
   }
 
-  private scheduleLayout(): void {
-    if (this.layoutTimer) clearTimeout(this.layoutTimer);
-
-    const gen = ++this.layoutGeneration;
-    this.layoutTimer = setTimeout(() => {
-      this.layoutTimer = undefined;
-      if (this.layoutGeneration === gen) {
-        void this.applyLayoutNow(this.storedLayout, this.storedMainPaneSize);
-      }
+  private scheduleLayout(targetPane: string | undefined): void {
+    const key = targetPane ?? '';
+    const pending = this.layoutTimers.get(key);
+    if (pending) clearTimeout(pending);
+
+    const timer = setTimeout(() => {
+      this.layoutTimers.delete(key);
+      void this.applyLayoutNow(
+        this.storedLayout,
+        this.storedMainPaneSize,
+        targetPane,
+      );
     }, TMUX_LAYOUT_DEBOUNCE_MS);
-    this.layoutTimer.unref?.();
+    this.layoutTimers.set(key, timer);
+    timer.unref?.();
   }
 
   private async applyLayoutNow(
     layout: MultiplexerLayout,
     mainPaneSize: number,
+    targetPane: string | undefined,
   ): Promise<void> {
     const tmux = await this.getBinary();
     if (!tmux) return;
@@ -167,7 +173,7 @@ export class TmuxMultiplexer implements Multiplexer {
       // Apply the layout
       const layoutResult = await this.runTmux(tmux, [
         'select-layout',
-        ...this.targetArgs(),
+        ...this.targetArgs(targetPane),
         layout,
       ]);
       if (layoutResult !== 0) return;
@@ -179,7 +185,7 @@ export class TmuxMultiplexer implements Multiplexer {
 
         const sizeResult = await this.runTmux(tmux, [
           'set-window-option',
-          ...this.targetArgs(),
+          ...this.targetArgs(targetPane),
           sizeOption,
           `${mainPaneSize}%`,
         ]);
@@ -188,7 +194,7 @@ export class TmuxMultiplexer implements Multiplexer {
         // Reapply layout to use the new size
         const reapplyResult = await this.runTmux(tmux, [
           'select-layout',
-          ...this.targetArgs(),
+          ...this.targetArgs(targetPane),
           layout,
         ]);
         if (reapplyResult !== 0) return;
@@ -228,7 +234,36 @@ export class TmuxMultiplexer implements Multiplexer {
     return this.binaryPath;
   }
 
-  private targetArgs(): string[] {
-    return this.targetPane ? ['-t', this.targetPane] : [];
+  private async splitPane(
+    tmux: string,
+    targetPane: string | undefined,
+    opencodeCmd: string,
+  ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
+    const args = [
+      'split-window',
+      '-h',
+      '-d',
+      '-P',
+      '-F',
+      '#{pane_id}',
+      ...this.targetArgs(targetPane),
+      opencodeCmd,
+    ];
+    log('[tmux] spawnPane: executing', { tmux, args });
+
+    const proc = crossSpawn([tmux, ...args], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const [exitCode, stdout, stderr] = await Promise.all([
+      proc.exited,
+      proc.stdout(),
+      proc.stderr(),
+    ]);
+    return { exitCode, stdout, stderr };
+  }
+
+  private targetArgs(targetPane = this.targetPane): string[] {
+    return targetPane ? ['-t', targetPane] : [];
   }
 }

+ 6 - 0
src/multiplexer/types.ts

@@ -14,6 +14,11 @@ export interface PaneResult {
   error?: 'unavailable' | 'not_found' | 'invalid_state' | 'hard';
 }
 
+export interface PaneSpawnOptions {
+  /** Root/parent OpenCode session that requested this child pane. */
+  parentSessionId?: string;
+}
+
 /**
  * Core multiplexer interface
  * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer,
@@ -44,6 +49,7 @@ export interface Multiplexer {
     description: string,
     serverUrl: string,
     directory: string,
+    options?: PaneSpawnOptions,
   ): Promise<PaneResult>;
 
   /**

+ 55 - 0
src/tui.test.ts

@@ -3,12 +3,15 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import { RGBA } from '@opentui/core';
+import { readTmuxPane } from './multiplexer/tmux-pane-registry';
 import {
+  type ActiveTmuxPaneRegistration,
   getContrastForeground,
   getSidebarAgentNames,
   readCompactSidebar,
   readConfigInvalid,
   splitSidebarModelId,
+  syncTmuxPaneRegistration,
   default as tuiPlugin,
 } from './tui';
 import type { TuiSnapshot } from './tui-state';
@@ -248,6 +251,58 @@ describe('tui plugin env disable', () => {
   });
 });
 
+describe('tmux pane registration', () => {
+  let originalEnv: typeof process.env;
+  let stateDirectory: string;
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    stateDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tmux-tui-'));
+    process.env.XDG_DATA_HOME = stateDirectory;
+    process.env.TMUX_PANE = '%42';
+  });
+
+  afterEach(() => {
+    fs.rmSync(stateDirectory, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('records the local pane for the active attached session', () => {
+    const registration: ActiveTmuxPaneRegistration = {
+      ownerPid: 100,
+      lastRecordedAt: 0,
+    };
+    const api = {
+      route: {
+        current: { name: 'session', params: { sessionID: 'root-session-b' } },
+      },
+    };
+
+    syncTmuxPaneRegistration(api as never, registration, 1_000);
+
+    expect(readTmuxPane('root-session-b', 1_000)).toBe('%42');
+  });
+
+  test('moves registration when the local TUI selects another session', () => {
+    const registration: ActiveTmuxPaneRegistration = {
+      ownerPid: 100,
+      lastRecordedAt: 0,
+    };
+    const api = {
+      route: {
+        current: { name: 'session', params: { sessionID: 'root-a' } },
+      },
+    };
+
+    syncTmuxPaneRegistration(api as never, registration, 1_000);
+    api.route.current.params.sessionID = 'root-b';
+    syncTmuxPaneRegistration(api as never, registration, 2_000);
+
+    expect(readTmuxPane('root-a', 2_000)).toBeUndefined();
+    expect(readTmuxPane('root-b', 2_000)).toBe('%42');
+  });
+});
+
 describe('getContrastForeground', () => {
   const white = RGBA.fromInts(255, 255, 255);
   const black = RGBA.fromInts(0, 0, 0);

+ 67 - 0
src/tui.ts

@@ -8,6 +8,10 @@ import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
 import { loadPluginConfig } from './config/loader';
+import {
+  recordTmuxPane,
+  removeTmuxPane,
+} from './multiplexer/tmux-pane-registry';
 import { openPresetManager } from './tui-preset';
 import {
   readTuiSnapshot,
@@ -25,6 +29,7 @@ const FALLBACK_SIDEBAR_AGENTS = SUBAGENT_NAMES.filter(
     !DEFAULT_DISABLED_AGENTS.includes(agent),
 );
 const BORDER = { type: 'single' };
+const TMUX_PANE_HEARTBEAT_MS = 10_000;
 
 type Child = JSX.Element | string | number | null | undefined | false;
 
@@ -75,6 +80,61 @@ function getTuiDirectory(api: {
   return api.state?.path?.directory ?? process.cwd();
 }
 
+export interface ActiveTmuxPaneRegistration {
+  sessionId?: string;
+  paneId?: string;
+  ownerPid: number;
+  lastRecordedAt: number;
+}
+
+function clearTmuxPaneRegistration(
+  registration: ActiveTmuxPaneRegistration,
+): void {
+  if (registration.sessionId && registration.paneId) {
+    removeTmuxPane(
+      registration.sessionId,
+      registration.paneId,
+      registration.ownerPid,
+    );
+  }
+  registration.sessionId = undefined;
+  registration.paneId = undefined;
+  registration.lastRecordedAt = 0;
+}
+
+export function syncTmuxPaneRegistration(
+  api: Pick<TuiPluginApi, 'route'>,
+  registration: ActiveTmuxPaneRegistration,
+  now = Date.now(),
+): void {
+  const paneId = process.env.TMUX_PANE;
+  const route = api.route.current;
+  const routeParams = 'params' in route ? route.params : undefined;
+  const sessionId =
+    route.name === 'session' &&
+    routeParams &&
+    typeof routeParams.sessionID === 'string'
+      ? routeParams.sessionID
+      : undefined;
+  const unchanged =
+    registration.sessionId === sessionId && registration.paneId === paneId;
+
+  if (!paneId || !sessionId) {
+    clearTmuxPaneRegistration(registration);
+    return;
+  }
+  if (unchanged && now - registration.lastRecordedAt < TMUX_PANE_HEARTBEAT_MS) {
+    return;
+  }
+  if (!unchanged) clearTmuxPaneRegistration(registration);
+
+  if (recordTmuxPane(sessionId, paneId, registration.ownerPid)) {
+    registration.sessionId = sessionId;
+    registration.paneId = paneId;
+    registration.lastRecordedAt = now;
+  }
+}
+
 export function splitSidebarModelId(model: string): {
   provider?: string;
   model: string;
@@ -358,9 +418,15 @@ const plugin: TuiPluginModule & { id: string } = {
     let configDirectory = getTuiDirectory(api);
     let { configInvalid, compactSidebar } = readConfigState(configDirectory);
     let snapshot = readTuiSnapshot(configDirectory);
+    const tmuxRegistration: ActiveTmuxPaneRegistration = {
+      ownerPid: process.pid,
+      lastRecordedAt: 0,
+    };
+    syncTmuxPaneRegistration(api, tmuxRegistration);
     const renderTimer = setInterval(async () => {
       try {
         const currentDirectory = getTuiDirectory(api);
+        syncTmuxPaneRegistration(api, tmuxRegistration);
         snapshot = await readTuiSnapshotAsync(currentDirectory);
         if (currentDirectory !== configDirectory) {
           configDirectory = currentDirectory;
@@ -375,6 +441,7 @@ const plugin: TuiPluginModule & { id: string } = {
 
     api.lifecycle.onDispose(() => {
       clearInterval(renderTimer);
+      clearTmuxPaneRegistration(tmuxRegistration);
     });
 
     api.slots.register({