Просмотр исходного кода

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

* Fix multiplexer pane targeting and attach directory handling

* Fix multiplexer factory test isolation
Alvin 3 месяцев назад
Родитель
Сommit
1f90acb014

+ 1 - 1
README.md

@@ -1,5 +1,5 @@
 <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><b>Open Multi Agent Suite</b> · Mix any models · Auto delegate tasks</p>
 


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

@@ -24,6 +24,7 @@ mock.module('../multiplexer', () => ({
 // Mock the plugin context
 function createMockContext(overrides?: {
   sessionStatusResult?: { data?: Record<string, { type: string }> };
+  directory?: string;
 }) {
   const defaultPort = process.env.OPENCODE_PORT ?? '4096';
   return {
@@ -34,6 +35,7 @@ function createMockContext(overrides?: {
         ),
       },
     },
+    directory: overrides?.directory ?? '/test/directory',
     serverUrl: new URL(`http://localhost:${defaultPort}`),
   } as any;
 }
@@ -83,6 +85,12 @@ describe('MultiplexerSessionManager', () => {
       });
 
       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 () => {
@@ -105,6 +113,33 @@ describe('MultiplexerSessionManager', () => {
       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 () => {
       const ctx = createMockContext();
       const manager = new MultiplexerSessionManager(ctx, {

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

@@ -26,7 +26,12 @@ interface TrackedSession {
 interface SessionEvent {
   type: string;
   properties?: {
-    info?: { id?: string; parentID?: string; title?: string };
+    info?: {
+      id?: string;
+      parentID?: string;
+      title?: string;
+      directory?: string;
+    };
     sessionID?: string;
     status?: { type: string };
   };
@@ -44,6 +49,7 @@ const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 export class MultiplexerSessionManager {
   private client: OpencodeClient;
   private serverUrl: string;
+  private directory: string;
   private multiplexer: Multiplexer | null = null;
   private sessions = new Map<string, TrackedSession>();
   private pollInterval?: ReturnType<typeof setInterval>;
@@ -51,6 +57,7 @@ export class MultiplexerSessionManager {
 
   constructor(ctx: PluginInput, config: MultiplexerConfig) {
     this.client = ctx.client;
+    this.directory = ctx.directory;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
     this.serverUrl =
       ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
@@ -88,6 +95,7 @@ export class MultiplexerSessionManager {
     const sessionId = info.id;
     const parentId = info.parentID;
     const title = info.title ?? 'Subagent';
+    const directory = info.directory ?? this.directory;
 
     // Skip if we're already tracking this session
     if (this.sessions.has(sessionId)) {
@@ -113,7 +121,7 @@ export class MultiplexerSessionManager {
     });
 
     const paneResult = await this.multiplexer
-      .spawnPane(sessionId, title, this.serverUrl)
+      .spawnPane(sessionId, title, this.serverUrl, directory)
       .catch((err) => {
         log('[multiplexer-session-manager] failed to spawn pane', {
           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 { 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 {
   const { type } = config;
@@ -20,12 +22,6 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
     return null;
   }
 
-  // Return cached instance if available
-  const cached = multiplexerCache.get(type);
-  if (cached) {
-    return cached;
-  }
-
   // Create new instance
   let multiplexer: Multiplexer;
   let actualType: MultiplexerType;
@@ -63,8 +59,6 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       return null;
   }
 
-  // Cache the instance under the actual type (not 'auto')
-  multiplexerCache.set(actualType, multiplexer);
   log(`[multiplexer] Created ${actualType} instance`);
 
   return multiplexer;
@@ -74,7 +68,7 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
  * Clear the multiplexer cache (useful for testing)
  */
 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 storedLayout: MultiplexerLayout;
   private storedMainPaneSize: number;
+  private targetPane = process.env.TMUX_PANE;
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     this.storedLayout = layout;
@@ -38,6 +39,7 @@ export class TmuxMultiplexer implements Multiplexer {
     sessionId: string,
     description: string,
     serverUrl: string,
+    directory: string,
   ): Promise<PaneResult> {
     const tmux = await this.getBinary();
     if (!tmux) {
@@ -47,7 +49,19 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // 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>
       const args = [
@@ -57,6 +71,7 @@ export class TmuxMultiplexer implements Multiplexer {
         '-P', // Print pane info
         '-F',
         '#{pane_id}', // Format: just the pane ID
+        ...this.targetArgs(),
         opencodeCmd,
       ];
 
@@ -164,10 +179,13 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // 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;
 
       // 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';
 
         const sizeProc = crossSpawn(
-          [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
+          [
+            tmux,
+            'set-window-option',
+            ...this.targetArgs(),
+            sizeOption,
+            `${mainPaneSize}%`,
+          ],
           {
             stdout: 'pipe',
             stderr: 'pipe',
@@ -185,10 +209,13 @@ export class TmuxMultiplexer implements Multiplexer {
         await sizeProc.exited;
 
         // 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;
       }
 
@@ -203,6 +230,10 @@ export class TmuxMultiplexer implements Multiplexer {
     return this.binaryPath;
   }
 
+  private targetArgs(): string[] {
+    return this.targetPane ? ['-t', this.targetPane] : [];
+  }
+
   private async findBinary(): Promise<string | null> {
     const isWindows = process.platform === 'win32';
     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 description - Human-readable description for the pane
    * @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(
     sessionId: string,
     description: string,
     serverUrl: string,
+    directory: string,
   ): Promise<PaneResult>;
 
   /**

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

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