Browse Source

Merge pull request #466 from alvinunreal/tmux-fix

fix tmux pane idle lifecycle
Alvin 2 months ago
parent
commit
ac266bbd48

+ 375 - 3
src/multiplexer/session-manager.test.ts

@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { MultiplexerSessionManager } from './session-manager';
 
 // Define the mock multiplexer
@@ -57,6 +57,10 @@ function createDeferred<T>() {
 }
 
 describe('MultiplexerSessionManager', () => {
+  const realDateNow = Date.now;
+  const originalChildEnv = process.env.OMOS_MULTIPLEXER_CHILD;
+  const originalTmuxPane = process.env.TMUX_PANE;
+
   beforeEach(() => {
     mockMultiplexer.spawnPane.mockReset();
     mockMultiplexer.spawnPane.mockResolvedValue({
@@ -67,6 +71,27 @@ describe('MultiplexerSessionManager', () => {
     mockMultiplexer.closePane.mockResolvedValue(true);
     mockMultiplexer.isInsideSession.mockReset();
     mockMultiplexer.isInsideSession.mockReturnValue(true);
+    Date.now = realDateNow;
+    delete process.env.OMOS_MULTIPLEXER_CHILD;
+    process.env.TMUX_PANE = '%controller';
+    (MultiplexerSessionManager as any).activeControllerKey = null;
+    (MultiplexerSessionManager as any).hasActiveController = false;
+  });
+
+  afterEach(() => {
+    Date.now = realDateNow;
+    if (originalChildEnv === undefined) {
+      delete process.env.OMOS_MULTIPLEXER_CHILD;
+    } else {
+      process.env.OMOS_MULTIPLEXER_CHILD = originalChildEnv;
+    }
+    if (originalTmuxPane === undefined) {
+      delete process.env.TMUX_PANE;
+    } else {
+      process.env.TMUX_PANE = originalTmuxPane;
+    }
+    (MultiplexerSessionManager as any).activeControllerKey = null;
+    (MultiplexerSessionManager as any).hasActiveController = false;
   });
 
   describe('constructor', () => {
@@ -78,6 +103,51 @@ describe('MultiplexerSessionManager', () => {
       );
       expect(manager).toBeDefined();
     });
+
+    test('disables pane spawning inside spawned child attach panes', async () => {
+      process.env.OMOS_MULTIPLEXER_CHILD = '1';
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'child-env', parentID: 'parent-env' } },
+      });
+
+      expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
+    });
+
+    test('only one plugin instance owns multiplexer pane spawning per pane', async () => {
+      const ctx = createMockContext();
+      const first = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+      const second = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await first.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'child-first', parentID: 'parent' } },
+      });
+      await second.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'child-second', parentID: 'parent' } },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
+        'child-first',
+        'Subagent',
+        `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
+        '/test/directory',
+      );
+    });
   });
 
   describe('onSessionCreated', () => {
@@ -206,15 +276,64 @@ describe('MultiplexerSessionManager', () => {
 
       expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
     });
+
+    test('does not respawn known sessions on replayed create events', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-known', parentID: 'parent-known' },
+        },
+      });
+      await (manager as any).closeSession('child-known', 'idle');
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-known', parentID: 'parent-known' },
+        },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
   });
 
   describe('polling and closure', () => {
-    test('closes pane when session becomes idle', async () => {
+    test('does not close pane on early idle status event', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-early-idle', parentID: 'p1' } },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-early-idle',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('closes pane when idle persists after busy, grace, and debounce', async () => {
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
         success: true,
         paneId: 'p-1',
       });
+      let now = 1_000;
+      Date.now = () => now;
 
       const manager = new MultiplexerSessionManager(
         ctx,
@@ -226,17 +345,227 @@ describe('MultiplexerSessionManager', () => {
         type: 'session.created',
         properties: { info: { id: 'c1', parentID: 'p1' } },
       });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: { sessionID: 'c1', status: { type: 'busy' } },
+      });
 
-      // Mock status
       ctx.client.session.status.mockResolvedValue({
         data: { c1: { type: 'idle' } },
       });
 
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      now += 16_000;
       await (manager as any).pollSessions();
 
       expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-1');
     });
 
+    test('busy status clears a pending idle debounce', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-idle-busy', parentID: 'p1' } },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'busy' },
+        },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-idle-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('busy during spawn is remembered so later idle can close', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+      const deferred = createDeferred<{ success: true; paneId: string }>();
+      mockMultiplexer.spawnPane.mockImplementationOnce(() => deferred.promise);
+
+      const createPromise = manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-spawn-busy', parentID: 'parent-spawn-busy' },
+        },
+      });
+      await Promise.resolve();
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-spawn-busy',
+          status: { type: 'busy' },
+        },
+      });
+
+      deferred.resolve({ success: true, paneId: 'p-spawn-busy' });
+      await createPromise;
+
+      ctx.client.session.status.mockResolvedValue({
+        data: { 'child-spawn-busy': { type: 'idle' } },
+      });
+      now += 16_000;
+      await (manager as any).pollSessions();
+      now += 7_500;
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('p-spawn-busy');
+    });
+
+    test('persistent pre-busy idle eventually closes after grace', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'child-pre-busy', parentID: 'parent' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-pre-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-pre-busy',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalled();
+    });
+
+    test('handles session.idle events like idle status events', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'child-idle-event', parentID: 'parent' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.idle',
+        properties: { sessionID: 'child-idle-event' },
+      });
+
+      now += 16_000;
+      await manager.onSessionStatus({
+        type: 'session.idle',
+        properties: { sessionID: 'child-idle-event' },
+      });
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalled();
+    });
+
+    test('does not close on missing status during initial grace period', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-missing-grace', parentID: 'p1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-missing-grace',
+          status: { type: 'busy' },
+        },
+      });
+
+      ctx.client.session.status.mockResolvedValue({ data: {} });
+      await (manager as any).pollSessions();
+      now += 16_000;
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+    });
+
+    test('closes on missing status only after busy, grace, and missing debounce', async () => {
+      const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c-missing-close', parentID: 'p1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'c-missing-close',
+          status: { type: 'busy' },
+        },
+      });
+
+      ctx.client.session.status.mockResolvedValue({ data: {} });
+      now += 16_000;
+      await (manager as any).pollSessions();
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      now += 7_500;
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalled();
+    });
+
     test('does not close on transient status absence', async () => {
       const ctx = createMockContext();
       const manager = new MultiplexerSessionManager(
@@ -284,10 +613,21 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-789',
+          status: { type: 'busy' },
+        },
+      });
+
+      (manager as any).sessions.get('child-789').createdAt -= 16_000;
       ctx.client.session.status.mockResolvedValue({
         data: { 'child-789': { type: 'idle' } },
       });
       await (manager as any).pollSessions();
+      (manager as any).sessions.get('child-789').idleSince -= 16_000;
+      await (manager as any).pollSessions();
 
       await manager.onSessionStatus({
         type: 'session.status',
@@ -310,6 +650,8 @@ describe('MultiplexerSessionManager', () => {
 
     test('respawns after in-flight idle close when busy resumes same session', async () => {
       const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
@@ -340,6 +682,24 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-close-race',
+          status: { type: 'busy' },
+        },
+      });
+
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-close-race',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+
       const idlePromise = manager.onSessionStatus({
         type: 'session.status',
         properties: {
@@ -375,6 +735,8 @@ describe('MultiplexerSessionManager', () => {
 
     test('does not respawn after in-flight close if session is deleted', async () => {
       const ctx = createMockContext();
+      let now = 1_000;
+      Date.now = () => now;
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
@@ -405,6 +767,16 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-delete-race',
+          status: { type: 'idle' },
+        },
+      });
+
+      now += 16_000;
+
       const idlePromise = manager.onSessionStatus({
         type: 'session.status',
         properties: {

+ 140 - 8
src/multiplexer/session-manager.ts

@@ -18,6 +18,8 @@ interface TrackedSession {
   directory: string;
   createdAt: number;
   lastSeenAt: number;
+  hasSeenBusy: boolean;
+  idleSince?: number;
   missingSince?: number;
 }
 
@@ -44,6 +46,8 @@ interface SessionEvent {
 type CloseReason = 'idle' | 'deleted' | 'missing' | 'timeout';
 
 const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
+const SESSION_IDLE_GRACE_MS = 15 * 1000;
+const SESSION_IDLE_DEBOUNCE_MS = POLL_INTERVAL_BACKGROUND_MS * 2;
 const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 
 /**
@@ -53,6 +57,9 @@ const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
  * with polling kept as a fallback for reliability.
  */
 export class MultiplexerSessionManager {
+  private static activeControllerKey: string | null = null;
+  private static hasActiveController = false;
+
   private client: OpencodeClient;
   private serverUrl: string;
   private directory: string;
@@ -60,6 +67,8 @@ export class MultiplexerSessionManager {
   private sessions = new Map<string, TrackedSession>();
   private knownSessions = new Map<string, KnownSession>();
   private spawningSessions = new Set<string>();
+  private pendingBusySessions = new Set<string>();
+  private pendingIdleSessions = new Map<string, number>();
   private closingSessions = new Map<string, Promise<void>>();
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
@@ -72,18 +81,45 @@ export class MultiplexerSessionManager {
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
 
     this.multiplexer = getMultiplexer(config);
+    const controllerKey = this.getControllerKey(config.type);
+    let isController = false;
+    if (controllerKey !== null) {
+      if (!MultiplexerSessionManager.hasActiveController) {
+        MultiplexerSessionManager.activeControllerKey = controllerKey;
+        MultiplexerSessionManager.hasActiveController = true;
+        isController = true;
+      } else {
+        isController = false;
+      }
+    }
+
     this.enabled =
+      process.env.OMOS_MULTIPLEXER_CHILD !== '1' &&
       config.type !== 'none' &&
-      this.multiplexer !== null &&
-      this.multiplexer.isInsideSession();
+      this.multiplexer?.isInsideSession() === true &&
+      isController;
 
     log('[multiplexer-session-manager] initialized', {
       enabled: this.enabled,
       type: config.type,
       serverUrl: this.serverUrl,
+      controllerKey,
+      activeControllerKey: MultiplexerSessionManager.activeControllerKey,
     });
   }
 
+  private getControllerKey(type: MultiplexerConfig['type']): string | null {
+    if (type === 'tmux') {
+      return process.env.TMUX_PANE ?? null;
+    }
+
+    if (type === 'zellij') {
+      return process.env.ZELLIJ_PANE_ID ?? process.env.ZELLIJ ?? null;
+    }
+
+    return null;
+  }
+
   async onSessionCreated(event: SessionEvent): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
     if (event.type !== 'session.created') return;
@@ -105,6 +141,18 @@ export class MultiplexerSessionManager {
       return;
     }
 
+    if (this.knownSessions.has(sessionId)) {
+      this.knownSessions.set(sessionId, {
+        parentId,
+        title,
+        directory,
+      });
+      log('[multiplexer-session-manager] known session create ignored', {
+        sessionId,
+      });
+      return;
+    }
+
     const closing = this.closingSessions.get(sessionId);
     if (closing) await closing;
 
@@ -169,6 +217,7 @@ export class MultiplexerSessionManager {
       }
 
       const now = Date.now();
+      const pendingIdleSince = this.pendingIdleSessions.get(sessionId);
       this.sessions.set(sessionId, {
         sessionId,
         paneId: paneResult.paneId,
@@ -177,7 +226,11 @@ export class MultiplexerSessionManager {
         directory,
         createdAt: now,
         lastSeenAt: now,
+        hasSeenBusy: this.pendingBusySessions.has(sessionId),
+        idleSince: pendingIdleSince,
       });
+      this.pendingBusySessions.delete(sessionId);
+      this.pendingIdleSessions.delete(sessionId);
 
       log('[multiplexer-session-manager] pane spawned', {
         sessionId,
@@ -192,17 +245,23 @@ export class MultiplexerSessionManager {
 
   async onSessionStatus(event: SessionEvent): Promise<void> {
     if (!this.enabled) return;
-    if (event.type !== 'session.status') return;
+    if (event.type !== 'session.status' && event.type !== 'session.idle') {
+      return;
+    }
 
     const sessionId = event.properties?.sessionID;
     if (!sessionId) return;
 
-    if (event.properties?.status?.type === 'idle') {
-      await this.closeSession(sessionId, 'idle');
+    if (
+      event.type === 'session.idle' ||
+      event.properties?.status?.type === 'idle'
+    ) {
+      await this.closeIfIdleConfirmed(sessionId, Date.now());
       return;
     }
 
     if (event.properties?.status?.type === 'busy') {
+      this.markBusy(sessionId);
       await this.respawnIfKnown(sessionId);
     }
   }
@@ -263,19 +322,28 @@ export class MultiplexerSessionManager {
         if (status) {
           tracked.lastSeenAt = now;
           tracked.missingSince = undefined;
-        } else if (!tracked.missingSince) {
+          if (status.type === 'busy') {
+            this.markBusy(sessionId, now);
+          }
+        } else if (
+          tracked.hasSeenBusy &&
+          now - tracked.createdAt >= SESSION_IDLE_GRACE_MS &&
+          !tracked.missingSince
+        ) {
           tracked.missingSince = now;
         }
 
+        const idleConfirmed = isIdle && this.markIdleAndCheck(tracked, now);
         const missingTooLong =
+          tracked.hasSeenBusy &&
           !!tracked.missingSince &&
           now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
         const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
 
-        if (isIdle || missingTooLong || isTimedOut) {
+        if (idleConfirmed || missingTooLong || isTimedOut) {
           sessionsToClose.push({
             sessionId,
-            reason: isIdle ? 'idle' : isTimedOut ? 'timeout' : 'missing',
+            reason: idleConfirmed ? 'idle' : isTimedOut ? 'timeout' : 'missing',
           });
         }
       }
@@ -294,6 +362,8 @@ export class MultiplexerSessionManager {
   ): Promise<void> {
     if (reason === 'deleted') {
       this.knownSessions.delete(sessionId);
+      this.pendingBusySessions.delete(sessionId);
+      this.pendingIdleSessions.delete(sessionId);
     }
 
     const existingClose = this.closingSessions.get(sessionId);
@@ -330,6 +400,60 @@ export class MultiplexerSessionManager {
     await closePromise;
   }
 
+  private async closeIfIdleConfirmed(
+    sessionId: string,
+    now: number,
+  ): Promise<void> {
+    const tracked = this.sessions.get(sessionId);
+    if (!tracked) {
+      if (
+        this.spawningSessions.has(sessionId) &&
+        this.knownSessions.has(sessionId)
+      ) {
+        this.pendingIdleSessions.set(sessionId, now);
+      }
+      return;
+    }
+
+    if (this.markIdleAndCheck(tracked, now)) {
+      await this.closeSession(sessionId, 'idle');
+    }
+  }
+
+  private markIdleAndCheck(tracked: TrackedSession, now: number): boolean {
+    tracked.lastSeenAt = now;
+    tracked.missingSince = undefined;
+
+    if (!tracked.idleSince) {
+      tracked.idleSince = now;
+      log('[multiplexer-session-manager] idle observed, waiting to confirm', {
+        sessionId: tracked.sessionId,
+      });
+      return false;
+    }
+
+    return (
+      now - tracked.createdAt >= SESSION_IDLE_GRACE_MS &&
+      now - tracked.idleSince >= SESSION_IDLE_DEBOUNCE_MS
+    );
+  }
+
+  private markBusy(sessionId: string, now = Date.now()): void {
+    const tracked = this.sessions.get(sessionId);
+    if (tracked) {
+      tracked.hasSeenBusy = true;
+      tracked.idleSince = undefined;
+      tracked.missingSince = undefined;
+      tracked.lastSeenAt = now;
+    } else if (
+      this.spawningSessions.has(sessionId) &&
+      this.knownSessions.has(sessionId)
+    ) {
+      this.pendingBusySessions.add(sessionId);
+      this.pendingIdleSessions.delete(sessionId);
+    }
+  }
+
   private async respawnIfKnown(sessionId: string): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
     const closing = this.closingSessions.get(sessionId);
@@ -407,6 +531,7 @@ export class MultiplexerSessionManager {
         directory: known.directory,
         createdAt: now,
         lastSeenAt: now,
+        hasSeenBusy: true,
       });
 
       log('[multiplexer-session-manager] pane respawned on busy', {
@@ -463,6 +588,13 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
+    this.pendingBusySessions.clear();
+    this.pendingIdleSessions.clear();
+
+    if (this.enabled) {
+      MultiplexerSessionManager.hasActiveController = false;
+      MultiplexerSessionManager.activeControllerKey = null;
+    }
 
     log('[multiplexer-session-manager] cleanup complete');
   }

+ 1 - 0
src/multiplexer/tmux/index.ts

@@ -58,6 +58,7 @@ export class TmuxMultiplexer implements Multiplexer {
       const quotedSessionId = quoteShellArg(sessionId);
 
       const opencodeCmd = [
+        'OMOS_MULTIPLEXER_CHILD=1',
         'opencode',
         'attach',
         quotedUrl,

+ 1 - 0
src/multiplexer/zellij/index.ts

@@ -479,6 +479,7 @@ function buildOpencodeAttachCommand(
   directory: string,
 ): string {
   return [
+    'OMOS_MULTIPLEXER_CHILD=1',
     'opencode',
     'attach',
     quoteShellArg(serverUrl),