Browse Source

Fix multiplexer pane targeting and attach directory handling (#329)

* Fix multiplexer pane targeting and attach directory handling

* Fix multiplexer factory test isolation
Alvin 3 months ago
parent
commit
1f90acb014

+ 1 - 1
README.md

@@ -1,5 +1,5 @@
 <div align="center">
 <div align="center">
-  <img src="img/team.png" alt="Pantheon agents" style="border-radius: 10px;" width="620">
+  <img src="img/team.jpeg" alt="Pantheon agents" style="border-radius: 10px;">
   <p><i>Seven divine beings emerged from the dawn of code, each an immortal master of their craft await your command to forge order from chaos and build what was once thought impossible.</i></p>
   <p><i>Seven divine beings emerged from the dawn of code, each an immortal master of their craft await your command to forge order from chaos and build what was once thought impossible.</i></p>
   <p><b>Open Multi Agent Suite</b> · Mix any models · Auto delegate tasks</p>
   <p><b>Open Multi Agent Suite</b> · Mix any models · Auto delegate tasks</p>
 
 

BIN
img/team.jpeg


+ 35 - 0
src/background/multiplexer-session-manager.test.ts

@@ -24,6 +24,7 @@ mock.module('../multiplexer', () => ({
 // Mock the plugin context
 // Mock the plugin context
 function createMockContext(overrides?: {
 function createMockContext(overrides?: {
   sessionStatusResult?: { data?: Record<string, { type: string }> };
   sessionStatusResult?: { data?: Record<string, { type: string }> };
+  directory?: string;
 }) {
 }) {
   const defaultPort = process.env.OPENCODE_PORT ?? '4096';
   const defaultPort = process.env.OPENCODE_PORT ?? '4096';
   return {
   return {
@@ -34,6 +35,7 @@ function createMockContext(overrides?: {
         ),
         ),
       },
       },
     },
     },
+    directory: overrides?.directory ?? '/test/directory',
     serverUrl: new URL(`http://localhost:${defaultPort}`),
     serverUrl: new URL(`http://localhost:${defaultPort}`),
   } as any;
   } as any;
 }
 }
@@ -83,6 +85,12 @@ describe('MultiplexerSessionManager', () => {
       });
       });
 
 
       expect(mockMultiplexer.spawnPane).toHaveBeenCalled();
       expect(mockMultiplexer.spawnPane).toHaveBeenCalled();
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
+        'child-123',
+        'Test Worker',
+        `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
+        '/test/directory',
+      );
     });
     });
 
 
     test('ignores sessions without parentID', async () => {
     test('ignores sessions without parentID', async () => {
@@ -105,6 +113,33 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
       expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
     });
     });
 
 
+    test('prefers child session directory when present', async () => {
+      const ctx = createMockContext({ directory: '/parent/directory' });
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'child-456',
+            parentID: 'parent-456',
+            title: 'Nested Worker',
+            directory: '/child/directory',
+          },
+        },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledWith(
+        'child-456',
+        'Nested Worker',
+        `http://localhost:${process.env.OPENCODE_PORT ?? '4096'}/`,
+        '/child/directory',
+      );
+    });
+
     test('ignores if disabled in config', async () => {
     test('ignores if disabled in config', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
       const manager = new MultiplexerSessionManager(ctx, {
       const manager = new MultiplexerSessionManager(ctx, {

+ 10 - 2
src/background/multiplexer-session-manager.ts

@@ -26,7 +26,12 @@ interface TrackedSession {
 interface SessionEvent {
 interface SessionEvent {
   type: string;
   type: string;
   properties?: {
   properties?: {
-    info?: { id?: string; parentID?: string; title?: string };
+    info?: {
+      id?: string;
+      parentID?: string;
+      title?: string;
+      directory?: string;
+    };
     sessionID?: string;
     sessionID?: string;
     status?: { type: string };
     status?: { type: string };
   };
   };
@@ -44,6 +49,7 @@ const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 export class MultiplexerSessionManager {
 export class MultiplexerSessionManager {
   private client: OpencodeClient;
   private client: OpencodeClient;
   private serverUrl: string;
   private serverUrl: string;
+  private directory: string;
   private multiplexer: Multiplexer | null = null;
   private multiplexer: Multiplexer | null = null;
   private sessions = new Map<string, TrackedSession>();
   private sessions = new Map<string, TrackedSession>();
   private pollInterval?: ReturnType<typeof setInterval>;
   private pollInterval?: ReturnType<typeof setInterval>;
@@ -51,6 +57,7 @@ export class MultiplexerSessionManager {
 
 
   constructor(ctx: PluginInput, config: MultiplexerConfig) {
   constructor(ctx: PluginInput, config: MultiplexerConfig) {
     this.client = ctx.client;
     this.client = ctx.client;
+    this.directory = ctx.directory;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
     this.serverUrl =
     this.serverUrl =
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
@@ -88,6 +95,7 @@ export class MultiplexerSessionManager {
     const sessionId = info.id;
     const sessionId = info.id;
     const parentId = info.parentID;
     const parentId = info.parentID;
     const title = info.title ?? 'Subagent';
     const title = info.title ?? 'Subagent';
+    const directory = info.directory ?? this.directory;
 
 
     // Skip if we're already tracking this session
     // Skip if we're already tracking this session
     if (this.sessions.has(sessionId)) {
     if (this.sessions.has(sessionId)) {
@@ -113,7 +121,7 @@ export class MultiplexerSessionManager {
     });
     });
 
 
     const paneResult = await this.multiplexer
     const paneResult = await this.multiplexer
-      .spawnPane(sessionId, title, this.serverUrl)
+      .spawnPane(sessionId, title, this.serverUrl, directory)
       .catch((err) => {
       .catch((err) => {
         log('[multiplexer-session-manager] failed to spawn pane', {
         log('[multiplexer-session-manager] failed to spawn pane', {
           error: String(err),
           error: String(err),

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

@@ -0,0 +1,71 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+
+async function importFreshFactory(suffix: string) {
+  return import(`./factory?test=${suffix}-${Date.now()}-${Math.random()}`);
+}
+
+describe('multiplexer factory', () => {
+  const originalTmux = process.env.TMUX;
+  const originalTmuxPane = process.env.TMUX_PANE;
+
+  afterEach(() => {
+    process.env.TMUX = originalTmux;
+    process.env.TMUX_PANE = originalTmuxPane;
+  });
+
+  test('returns a fresh tmux instance per call', async () => {
+    process.env.TMUX = '/tmp/tmux-1000/default,123,0';
+    process.env.TMUX_PANE = '%1';
+
+    const { getMultiplexer } = await importFreshFactory('tmux-first');
+
+    const first = getMultiplexer({
+      type: 'tmux',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+    });
+
+    process.env.TMUX_PANE = '%2';
+
+    const { getMultiplexer: getMultiplexerAgain } =
+      await importFreshFactory('tmux-second');
+
+    const second = getMultiplexerAgain({
+      type: 'tmux',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+    });
+
+    expect(first).not.toBeNull();
+    expect(second).not.toBeNull();
+    expect(Object.is(first, second)).toBe(false);
+  });
+
+  test('returns a fresh auto-detected tmux instance per call', async () => {
+    process.env.TMUX = '/tmp/tmux-1000/default,123,0';
+    process.env.TMUX_PANE = '%1';
+
+    const { getMultiplexer } = await importFreshFactory('auto-first');
+
+    const first = getMultiplexer({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+    });
+
+    process.env.TMUX_PANE = '%2';
+
+    const { getMultiplexer: getMultiplexerAgain } =
+      await importFreshFactory('auto-second');
+
+    const second = getMultiplexerAgain({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+    });
+
+    expect(first).not.toBeNull();
+    expect(second).not.toBeNull();
+    expect(Object.is(first, second)).toBe(false);
+  });
+});

+ 6 - 12
src/multiplexer/factory.ts

@@ -8,10 +8,12 @@ import { TmuxMultiplexer } from './tmux';
 import type { Multiplexer } from './types';
 import type { Multiplexer } from './types';
 import { ZellijMultiplexer } from './zellij';
 import { ZellijMultiplexer } from './zellij';
 
 
-const multiplexerCache = new Map<MultiplexerType | 'auto', Multiplexer>();
-
 /**
 /**
- * Create or retrieve a multiplexer instance based on config
+ * Create a multiplexer instance based on config.
+ *
+ * Do not cache instances: tmux/zellij integrations may depend on
+ * per-process environment like TMUX_PANE/ZELLIJ, which should be captured
+ * fresh for each plugin context.
  */
  */
 export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
 export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
   const { type } = config;
   const { type } = config;
@@ -20,12 +22,6 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
     return null;
     return null;
   }
   }
 
 
-  // Return cached instance if available
-  const cached = multiplexerCache.get(type);
-  if (cached) {
-    return cached;
-  }
-
   // Create new instance
   // Create new instance
   let multiplexer: Multiplexer;
   let multiplexer: Multiplexer;
   let actualType: MultiplexerType;
   let actualType: MultiplexerType;
@@ -63,8 +59,6 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       return null;
       return null;
   }
   }
 
 
-  // Cache the instance under the actual type (not 'auto')
-  multiplexerCache.set(actualType, multiplexer);
   log(`[multiplexer] Created ${actualType} instance`);
   log(`[multiplexer] Created ${actualType} instance`);
 
 
   return multiplexer;
   return multiplexer;
@@ -74,7 +68,7 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
  * Clear the multiplexer cache (useful for testing)
  * Clear the multiplexer cache (useful for testing)
  */
  */
 export function clearMultiplexerCache(): void {
 export function clearMultiplexerCache(): void {
-  multiplexerCache.clear();
+  // No-op: multiplexers are no longer cached.
 }
 }
 
 
 /**
 /**

+ 45 - 10
src/multiplexer/tmux/index.ts

@@ -14,6 +14,7 @@ export class TmuxMultiplexer implements Multiplexer {
   private hasChecked = false;
   private hasChecked = false;
   private storedLayout: MultiplexerLayout;
   private storedLayout: MultiplexerLayout;
   private storedMainPaneSize: number;
   private storedMainPaneSize: number;
+  private targetPane = process.env.TMUX_PANE;
 
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     this.storedLayout = layout;
     this.storedLayout = layout;
@@ -38,6 +39,7 @@ export class TmuxMultiplexer implements Multiplexer {
     sessionId: string,
     sessionId: string,
     description: string,
     description: string,
     serverUrl: string,
     serverUrl: string,
+    directory: string,
   ): Promise<PaneResult> {
   ): Promise<PaneResult> {
     const tmux = await this.getBinary();
     const tmux = await this.getBinary();
     if (!tmux) {
     if (!tmux) {
@@ -47,7 +49,19 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
     try {
     try {
       // Build the attach command
       // Build the attach command
-      const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+      const quotedDirectory = quoteShellArg(directory);
+      const quotedUrl = quoteShellArg(serverUrl);
+      const quotedSessionId = quoteShellArg(sessionId);
+
+      const opencodeCmd = [
+        'opencode',
+        'attach',
+        quotedUrl,
+        '--session',
+        quotedSessionId,
+        '--dir',
+        quotedDirectory,
+      ].join(' ');
 
 
       // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
       // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
       const args = [
       const args = [
@@ -57,6 +71,7 @@ export class TmuxMultiplexer implements Multiplexer {
         '-P', // Print pane info
         '-P', // Print pane info
         '-F',
         '-F',
         '#{pane_id}', // Format: just the pane ID
         '#{pane_id}', // Format: just the pane ID
+        ...this.targetArgs(),
         opencodeCmd,
         opencodeCmd,
       ];
       ];
 
 
@@ -164,10 +179,13 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
     try {
     try {
       // Apply the layout
       // Apply the layout
-      const layoutProc = crossSpawn([tmux, 'select-layout', layout], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
+      const layoutProc = crossSpawn(
+        [tmux, 'select-layout', ...this.targetArgs(), layout],
+        {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        },
+      );
       await layoutProc.exited;
       await layoutProc.exited;
 
 
       // For main-* layouts, set the main pane size
       // For main-* layouts, set the main pane size
@@ -176,7 +194,13 @@ export class TmuxMultiplexer implements Multiplexer {
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
 
 
         const sizeProc = crossSpawn(
         const sizeProc = crossSpawn(
-          [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
+          [
+            tmux,
+            'set-window-option',
+            ...this.targetArgs(),
+            sizeOption,
+            `${mainPaneSize}%`,
+          ],
           {
           {
             stdout: 'pipe',
             stdout: 'pipe',
             stderr: 'pipe',
             stderr: 'pipe',
@@ -185,10 +209,13 @@ export class TmuxMultiplexer implements Multiplexer {
         await sizeProc.exited;
         await sizeProc.exited;
 
 
         // Reapply layout to use the new size
         // Reapply layout to use the new size
-        const reapplyProc = crossSpawn([tmux, 'select-layout', layout], {
-          stdout: 'pipe',
-          stderr: 'pipe',
-        });
+        const reapplyProc = crossSpawn(
+          [tmux, 'select-layout', ...this.targetArgs(), layout],
+          {
+            stdout: 'pipe',
+            stderr: 'pipe',
+          },
+        );
         await reapplyProc.exited;
         await reapplyProc.exited;
       }
       }
 
 
@@ -203,6 +230,10 @@ export class TmuxMultiplexer implements Multiplexer {
     return this.binaryPath;
     return this.binaryPath;
   }
   }
 
 
+  private targetArgs(): string[] {
+    return this.targetPane ? ['-t', this.targetPane] : [];
+  }
+
   private async findBinary(): Promise<string | null> {
   private async findBinary(): Promise<string | null> {
     const isWindows = process.platform === 'win32';
     const isWindows = process.platform === 'win32';
     const cmd = isWindows ? 'where' : 'which';
     const cmd = isWindows ? 'where' : 'which';
@@ -245,3 +276,7 @@ export class TmuxMultiplexer implements Multiplexer {
     }
     }
   }
   }
 }
 }
+
+function quoteShellArg(value: string): string {
+  return `'${value.replace(/'/g, `'\\''`)}'`;
+}

+ 2 - 1
src/multiplexer/types.ts

@@ -34,12 +34,13 @@ export interface Multiplexer {
    * @param sessionId - The OpenCode session ID to attach to
    * @param sessionId - The OpenCode session ID to attach to
    * @param description - Human-readable description for the pane
    * @param description - Human-readable description for the pane
    * @param serverUrl - The OpenCode server URL to attach to
    * @param serverUrl - The OpenCode server URL to attach to
-   * @returns PaneResult with pane ID for later cleanup
+   * @param directory - The project directory to attach from
    */
    */
   spawnPane(
   spawnPane(
     sessionId: string,
     sessionId: string,
     description: string,
     description: string,
     serverUrl: string,
     serverUrl: string,
+    directory: string,
   ): Promise<PaneResult>;
   ): Promise<PaneResult>;
 
 
   /**
   /**

+ 48 - 8
src/multiplexer/zellij/index.ts

@@ -52,6 +52,7 @@ export class ZellijMultiplexer implements Multiplexer {
     sessionId: string,
     sessionId: string,
     description: string,
     description: string,
     serverUrl: string,
     serverUrl: string,
+    directory: string,
   ): Promise<PaneResult> {
   ): Promise<PaneResult> {
     const zellij = await this.getBinary();
     const zellij = await this.getBinary();
     if (!zellij) return { success: false };
     if (!zellij) return { success: false };
@@ -72,6 +73,7 @@ export class ZellijMultiplexer implements Multiplexer {
           this.firstPaneId,
           this.firstPaneId,
           sessionId,
           sessionId,
           serverUrl,
           serverUrl,
+          directory,
           description,
           description,
         );
         );
         if (success) {
         if (success) {
@@ -86,6 +88,7 @@ export class ZellijMultiplexer implements Multiplexer {
         zellij,
         zellij,
         sessionId,
         sessionId,
         serverUrl,
         serverUrl,
+        directory,
         description,
         description,
       );
       );
     } catch {
     } catch {
@@ -97,9 +100,14 @@ export class ZellijMultiplexer implements Multiplexer {
     zellij: string,
     zellij: string,
     sessionId: string,
     sessionId: string,
     serverUrl: string,
     serverUrl: string,
+    directory: string,
     description: string,
     description: string,
   ): Promise<PaneResult> {
   ): Promise<PaneResult> {
-    const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+    const opencodeCmd = buildOpencodeAttachCommand(
+      sessionId,
+      serverUrl,
+      directory,
+    );
     const paneName = description.slice(0, 30).replace(/"/g, '\\"');
     const paneName = description.slice(0, 30).replace(/"/g, '\\"');
 
 
     const currentTabId = await this.getCurrentTabId(zellij);
     const currentTabId = await this.getCurrentTabId(zellij);
@@ -115,7 +123,7 @@ export class ZellijMultiplexer implements Multiplexer {
         '--close-on-exit',
         '--close-on-exit',
         '--',
         '--',
         'sh',
         'sh',
-        '-c',
+        '-lc',
         opencodeCmd,
         opencodeCmd,
       ];
       ];
 
 
@@ -157,7 +165,7 @@ export class ZellijMultiplexer implements Multiplexer {
       '--close-on-exit',
       '--close-on-exit',
       '--',
       '--',
       'sh',
       'sh',
-      '-c',
+      '-lc',
       opencodeCmd,
       opencodeCmd,
     ];
     ];
 
 
@@ -193,10 +201,15 @@ export class ZellijMultiplexer implements Multiplexer {
     paneId: string,
     paneId: string,
     sessionId: string,
     sessionId: string,
     serverUrl: string,
     serverUrl: string,
+    directory: string,
     description: string,
     description: string,
   ): Promise<boolean> {
   ): Promise<boolean> {
     try {
     try {
-      const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+      );
 
 
       await crossSpawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
       await crossSpawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
         stdout: 'ignore',
         stdout: 'ignore',
@@ -208,10 +221,13 @@ export class ZellijMultiplexer implements Multiplexer {
         { stdout: 'ignore', stderr: 'ignore' },
         { stdout: 'ignore', stderr: 'ignore' },
       ).exited;
       ).exited;
 
 
-      await crossSpawn([zellij, 'action', 'write-chars', opencodeCmd], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
+      await crossSpawn(
+        [zellij, 'action', 'write-chars', buildShellLaunchCommand(opencodeCmd)],
+        {
+          stdout: 'ignore',
+          stderr: 'ignore',
+        },
+      ).exited;
 
 
       await crossSpawn([zellij, 'action', 'write-chars', '\n'], {
       await crossSpawn([zellij, 'action', 'write-chars', '\n'], {
         stdout: 'ignore',
         stdout: 'ignore',
@@ -456,3 +472,27 @@ export class ZellijMultiplexer implements Multiplexer {
     }
     }
   }
   }
 }
 }
+
+function buildOpencodeAttachCommand(
+  sessionId: string,
+  serverUrl: string,
+  directory: string,
+): string {
+  return [
+    'opencode',
+    'attach',
+    quoteShellArg(serverUrl),
+    '--session',
+    quoteShellArg(sessionId),
+    '--dir',
+    quoteShellArg(directory),
+  ].join(' ');
+}
+
+function buildShellLaunchCommand(command: string): string {
+  return ['sh', '-lc', quoteShellArg(command)].join(' ');
+}
+
+function quoteShellArg(value: string): string {
+  return `'${value.replace(/'/g, `'\\''`)}'`;
+}