Răsfoiți Sursa

Merge pull request #1047 from ermanhavuc/fix/1016-tmux-attached-session-target

fix(tmux): route subagent panes to attached parent session
Alvin 4 săptămâni în urmă
părinte
comite
b50eb24fb6

+ 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({