瀏覽代碼

Merge pull request #704 from mhenke/fix/703-share-closepane-shutdown

refactor(multiplexer): share closePane graceful-shutdown in one helper
Alvin 1 月之前
父節點
當前提交
7b6e3e1386

+ 351 - 0
docs/superpowers/plans/2026-07-08-share-closepane-shutdown.md

@@ -0,0 +1,351 @@
+# Share closePane graceful-shutdown across multiplexer backends
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extract the duplicated Ctrl+C → 250ms → kill/close pane lifecycle from tmux/zellij/herdr `closePane` into one `gracefulClosePane` helper in `shared.ts`, and force the hidden exit-code/guard inconsistencies into the open via an `options` param.
+
+**Architecture:** One shared helper takes the binary, paneId, and the two backend-specific command arrays (`ctrlC`, `close`) plus an `options` object for the divergent bits (accept exit code 1, empty-paneId returns true). Each backend's `closePane` shrinks to: guard → `getBinary()` → `return gracefulClosePane(...)`. No base class, no interface change. `session-manager.ts` is untouched (it already uses the `Multiplexer` interface).
+
+**Tech Stack:** TypeScript, Bun test, existing `crossSpawn` from `../utils/compat`, existing `log` from `../utils/logger`.
+
+---
+
+### Task 1: Add `gracefulClosePane` helper + tests
+
+**Files:**
+- Modify: `src/multiplexer/shared.ts`
+- Create: `src/multiplexer/shared.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
+import { gracefulClosePane } from './shared';
+import { crossSpawn } from '../utils/compat';
+
+const DELAY_MS = 250;
+
+function fakeProc(exitCode: number, stderr = '') {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(stderr),
+  } as unknown as ReturnType<typeof crossSpawn>;
+}
+
+describe('gracefulClosePane', () => {
+  beforeEach(() => mock.module('../utils/compat', () => ({ crossSpawn: mock() })));
+  afterEach(() => mock.restore());
+
+  it('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
+    const calls: string[][] = [];
+    let ctrlCTime = 0;
+    let closeTime = 0;
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation((args: string[]) => {
+      if (args.includes('C-c') || args.includes('\u0003') || args.includes('ctrl+c')) {
+        ctrlCTime = Date.now();
+      } else {
+        closeTime = Date.now();
+      }
+      calls.push(args);
+      return fakeProc(0);
+    });
+
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+
+    expect(ok).toBe(true);
+    expect(calls).toHaveLength(2);
+    expect(closeTime - ctrlCTime).toBeGreaterThanOrEqual(DELAY_MS - 20);
+  });
+
+  it('returns true when acceptExitCode1 and exit code is 1', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(1));
+    const ok = await gracefulClosePane('zellij', 'terminal_1', {
+      ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
+      acceptExitCode1: true,
+    });
+    expect(ok).toBe(true);
+  });
+
+  it('returns false on exit 1 when acceptExitCode1 is false', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(1));
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+    expect(ok).toBe(false);
+  });
+
+  it('returns emptyPaneReturnsTrue when paneId is empty', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(0));
+    const ok = await gracefulClosePane('zellij', '', {
+      ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', ''],
+      emptyPaneReturnsTrue: true,
+    });
+    expect(ok).toBe(true);
+    expect((crossSpawn as unknown as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
+  });
+
+  it('returns false when binary is null', async () => {
+    const ok = await gracefulClosePane(null, '%1', {
+      ctrlC: ['x'],
+      close: ['y'],
+    });
+    expect(ok).toBe(false);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test src/multiplexer/shared.test.ts`
+Expected: FAIL — `gracefulClosePane` is not exported.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `src/multiplexer/shared.ts`:
+
+```typescript
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test src/multiplexer/shared.test.ts`
+Expected: PASS (5 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/multiplexer/shared.ts src/multiplexer/shared.test.ts
+git commit -m "feat(multiplexer): add gracefulClosePane helper with tests"
+```
+
+---
+
+### Task 2: Rewrite tmux `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/tmux/index.ts:115-164`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 115-164 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const tmux = await this.getBinary();
+    return gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+      // tmux: empty paneId is a real error, exit 0 only.
+      emptyPaneReturnsTrue: false,
+    });
+  }
+```
+
+Note: tmux used to call `this.scheduleLayout()` on success. That rebalance is a tmux-specific concern, not part of the shared shutdown. Preserve it by wrapping:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const tmux = await this.getBinary();
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/tmux/index.ts`, add to the shared import (or new line):
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + existing tmux tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: typecheck clean, all multiplexer tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/tmux/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in tmux"
+```
+
+---
+
+### Task 3: Rewrite zellij `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/zellij/index.ts:496-525`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 496-525 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const zellij = await this.getBinary();
+    return gracefulClosePane(zellij, paneId, {
+      ctrlC: ['action', 'write', '--pane-id', paneId, '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/zellij/index.ts`:
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: clean + pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/zellij/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in zellij"
+```
+
+---
+
+### Task 4: Rewrite herdr `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.ts:155-200`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 155-200 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const herdr = await this.getBinary();
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/herdr/index.ts`:
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: clean + pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in herdr"
+```
+
+---
+
+### Task 5: Final verification + lint
+
+**Files:**
+- None new
+
+- [ ] **Step 1: Run full check + test suite**
+
+Run: `bun run check:ci && bun run typecheck && bun test`
+Expected: Biome clean, types clean, all tests pass.
+
+- [ ] **Step 2: Confirm no orphaned duplicate logic**
+
+Run: `grep -rn "setTimeout(r, 250)" src/multiplexer/`
+Expected: only the `GRACEFUL_SHUTDOWN_DELAY_MS` usage inside `shared.ts`. No per-backend 250ms copies remain.
+
+- [ ] **Step 3: Commit (if any lint auto-fix applied) and push branch**
+
+```bash
+git add -A
+git commit -m "chore(multiplexer): lint fixes for closePane refactor" || echo "nothing to commit"
+git push -u origin fix/703-share-closepane-shutdown
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:** Issue #703 asks for one `gracefulClosePane(binary, paneId, { ctrlC, close }, options?)` helper in `shared.ts`. Task 1 delivers exactly that signature + tests. Tasks 2-4 wire all three backends. Task 5 verifies. The "inconsistencies forced into the open" (exit 0 vs 0||1, empty guard false vs true) are handled by `acceptExitCode1` / `emptyPaneReturnsTrue` options, preserving each backend's real behavior rather than silently unifying it. Covered.
+
+**2. Placeholder scan:** No TBD/TODO. Every step has code or exact command. The tmux `scheduleLayout` nuance is explicitly handled, not deferred.
+
+**3. Type consistency:** `gracefulClosePane(binary: string | null, paneId: string, options: GracefulClosePaneOptions)` is defined in Task 1 and called identically in Tasks 2-4. `ctrlC`/`close` are `string[]`. `acceptExitCode1`/`emptyPaneReturnsTrue` are optional booleans. Consistent.
+
+**Ponytail note:** Deliberately NOT extracting `spawnPane` (diverges too much per @oracle) and NOT building a `MultiplexerBase` class (over-engineering for this scope). The single helper is the smallest change that removes the 3-place shutdown hazard. `session-manager.ts` is correctly left alone.

+ 11 - 44
src/multiplexer/herdr/index.ts

@@ -15,7 +15,11 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 type HerdrPaneDirection = 'right' | 'down';
@@ -153,50 +157,13 @@ export class HerdrMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const herdr = await this.getBinary();
-    if (!herdr) {
-      log('[herdr] closePane: herdr binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[herdr] closePane: sending Ctrl+C', { paneId });
-      await crossSpawn([herdr, 'pane', 'send-keys', paneId, 'ctrl+c'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      log('[herdr] closePane: closing pane', { paneId });
-      const proc = crossSpawn([herdr, 'pane', 'close', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[herdr] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0 || exitCode === 1) {
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[herdr] closePane: failed (pane may already be closed)', {
-        paneId,
-      });
-      return false;
-    } catch (err) {
-      log('[herdr] closePane: exception', { error: String(err) });
-      return false;
-    }
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(

+ 105 - 0
src/multiplexer/shared.test.ts

@@ -0,0 +1,105 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+};
+
+const crossSpawnMock = mock(
+  (_args: string[]): SpawnResult => ({
+    exited: Promise.resolve(0),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(''),
+  }),
+);
+
+mock.module('../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+async function importShared() {
+  return import(`./shared?test=${importCounter++}`);
+}
+
+describe('gracefulClosePane', () => {
+  afterEach(() => {
+    crossSpawnMock.mockReset();
+  });
+
+  test('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
+    const calls: string[][] = [];
+
+    crossSpawnMock.mockImplementation((args: string[]) => {
+      calls.push(args);
+      return {
+        exited: Promise.resolve(0),
+        stdout: () => Promise.resolve(''),
+        stderr: () => Promise.resolve(''),
+      };
+    });
+
+    const { gracefulClosePane } =
+      await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+
+    expect(ok).toBe(true);
+    expect(calls).toHaveLength(2);
+  });
+
+  test('returns true when acceptExitCode1 and exit code is 1', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', 'terminal_1', {
+      ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
+      acceptExitCode1: true,
+    });
+    expect(ok).toBe(true);
+  });
+
+  test('returns false on exit 1 when acceptExitCode1 is false', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+    expect(ok).toBe(false);
+  });
+
+  test('returns emptyPaneReturnsTrue when paneId is empty', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', '', {
+      ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', ''],
+      emptyPaneReturnsTrue: true,
+    });
+    expect(ok).toBe(true);
+    expect(crossSpawnMock.mock.calls).toHaveLength(0);
+  });
+
+  test('returns false when binary is null', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane(null, '%1', {
+      ctrlC: ['x'],
+      close: ['y'],
+    });
+    expect(ok).toBe(false);
+  });
+});

+ 46 - 0
src/multiplexer/shared.ts

@@ -89,3 +89,49 @@ export async function findBinary(
     return null;
   }
 }
+
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}

+ 11 - 48
src/multiplexer/tmux/index.ts

@@ -5,7 +5,11 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 const TMUX_LAYOUT_DEBOUNCE_MS = 150;
@@ -113,54 +117,13 @@ export class TmuxMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId) {
-      log('[tmux] closePane: no paneId provided');
-      return false;
-    }
-
     const tmux = await this.getBinary();
-    if (!tmux) {
-      log('[tmux] closePane: tmux binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[tmux] closePane: sending Ctrl+C', { paneId });
-      const ctrlCProc = crossSpawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      await ctrlCProc.exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Kill the pane
-      log('[tmux] closePane: killing pane', { paneId });
-      const proc = crossSpawn([tmux, 'kill-pane', '-t', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0) {
-        // Rebalance panes after bursts of child sessions settle.
-        this.scheduleLayout();
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[tmux] closePane: failed (pane may already be closed)', { paneId });
-      return false;
-    } catch (err) {
-      log('[tmux] closePane: exception', { error: String(err) });
-      return false;
-    }
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
   }
 
   async applyLayout(

+ 7 - 27
src/multiplexer/zellij/index.ts

@@ -17,6 +17,7 @@ import { crossSpawn } from '../../utils/compat';
 import {
   buildOpencodeAttachCommand,
   findBinary,
+  gracefulClosePane,
   quoteShellArg,
 } from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
@@ -494,34 +495,13 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const zellij = await this.getBinary();
-    if (!zellij) return false;
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      await crossSpawn(
-        [zellij, 'action', 'write', '--pane-id', paneId, '\u0003'],
-        {
-          stdout: 'ignore',
-          stderr: 'ignore',
-        },
-      ).exited;
-
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      const proc = crossSpawn(
-        [zellij, 'action', 'close-pane', '--pane-id', paneId],
-        { stdout: 'pipe', stderr: 'pipe' },
-      );
-
-      const exitCode = await proc.exited;
-      return exitCode === 0 || exitCode === 1;
-    } catch {
-      return false;
-    }
+    return gracefulClosePane(zellij, paneId, {
+      ctrlC: ['action', 'write', '--pane-id', paneId, '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(