Browse Source

Merge pull request #697 from mhenke/feat/herdr-main-vertical-668

refactor(herdr): simplify main-vertical agent area tracking
Mike Henke 3 weeks ago
parent
commit
4013cd054e

+ 1 - 0
.gitignore

@@ -60,6 +60,7 @@ PR-NOTES.md
 REVIEW.md
 !docs/goal.md
 docs/plans
+docs/superpowers
 
 # Python
 __pycache__/

+ 11 - 5
docs/multiplexer-integration.md

@@ -219,11 +219,17 @@ For Herdr:
 
 | Layout | Herdr behavior |
 |--------|-----------------|
-| `main-vertical` | Opens new subagent panes to the right |
-| `main-horizontal` | Opens new subagent panes down |
-| `even-horizontal` | Opens new subagent panes to the right |
-| `even-vertical` | Opens new subagent panes down |
-| `tiled` | Opens new subagent panes to the right |
+| `main-vertical` | Parent OpenCode pane stays on the left. First subagent opens in a right-side pane; subsequent subagents stack vertically in that right column. Parent pane remains dominant. |
+| `main-horizontal` | Each subagent splits below the parent (down). |
+| `even-horizontal` | Each subagent splits to the right of the parent. |
+| `even-vertical` | Each subagent splits below the parent. |
+| `tiled` | Each subagent splits to the right of the parent. |
+
+**Note:** Herdr has no layout rebalancing API like tmux's `select-layout`.
+The `main_pane_size` config is ignored. The `main-vertical` layout approximates
+tmux's behavior by tracking the first right-side pane and stacking later agents
+vertically within it. If the agent-area pane is closed, the next spawn
+re-creates it from the parent.
 
 > **Note:** `main_pane_size` is ignored by herdr. All layouts split from the parent pane.
 

+ 0 - 735
docs/superpowers/plans/2026-07-06-background-job-coordinator.md

@@ -1,735 +0,0 @@
-# BackgroundJobCoordinator: Move Lifecycle Policy from Multiplexer to Coordinator
-
-> **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:** Centralize background job lifecycle policy (deferred close decisions) in the coordinator, not the multiplexer.
-
-**Architecture:** The coordinator owns the `deferredIdleCloses` tracking and decides when sessions should close. The multiplexer becomes a thin pane manager that queries the coordinator before closing. The subscription wiring stays in `index.ts` (not in the multiplexer constructor) to avoid multi-instance issues.
-
-**Tech Stack:** TypeScript, Bun
-
-## Global Constraints
-
-- No new dependencies
-- All existing tests must pass
-- Follow ponytail principles: minimal code, YAGNI
-- Match existing code style (biome formatter)
-
----
-
-## File Map
-
-| File | Action | Responsibility |
-|------|--------|----------------|
-| `src/utils/background-job-coordinator.ts` | Modify | Add `deferredIdleCloses` tracking, `deferIfRunning()`, `retryDeferredClose()`, `clearDeferredClose()` |
-| `src/utils/background-job-coordinator.test.ts` | Create | Test lifecycle policy logic |
-| `src/utils/background-job-store.ts` | Modify | Add lifecycle methods to interface |
-| `src/utils/background-job-board.ts` | Modify | Add stubs to satisfy interface |
-| `src/multiplexer/session-manager.ts` | Modify | Remove `deferredIdleCloses`, remove `retryDeferredIdleClose()`, query coordinator |
-| `src/multiplexer/session-manager.test.ts` | Modify | Update test setup to use coordinator |
-| `src/index.ts` | Modify | Update wiring: keep subscription, remove retryDeferredIdleClose call |
-
----
-
-### Task 1: Add lifecycle methods to BackgroundJobStore interface
-
-**Files:**
-- Modify: `src/utils/background-job-store.ts`
-
-**Interfaces:**
-- Produces: `deferIfRunning(sessionId: string): boolean`, `retryDeferredClose(sessionId: string): boolean`, `clearDeferredClose(sessionId: string): void`
-
-- [ ] **Step 1: Add new methods to BackgroundJobStore interface**
-
-```typescript
-// In src/utils/background-job-store.ts, add after existing methods:
-
-  // ── Lifecycle policy ─────────────────────────────────────────────
-  /** Evaluate close policy. Returns true if session should close now.
-   *  Mutates deferred state: adds to deferred set if running, removes if not. */
-  deferIfRunning(sessionId: string): boolean;
-  /** Retry closing a deferred session. Returns true if session should now close. */
-  retryDeferredClose(sessionId: string): boolean;
-  /** Clear deferred close state for a session being deleted. */
-  clearDeferredClose(sessionId: string): void;
-```
-
-- [ ] **Step 2: Run typecheck to verify interface change**
-
-Run: `bun run typecheck`
-Expected: FAIL - BackgroundJobBoard and BackgroundJobCoordinator don't implement new methods yet
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/utils/background-job-store.ts
-git commit -m "feat: add lifecycle methods to BackgroundJobStore interface"
-```
-
----
-
-### Task 2: Implement lifecycle policy in BackgroundJobCoordinator
-
-**Files:**
-- Modify: `src/utils/background-job-coordinator.ts`
-
-**Interfaces:**
-- Consumes: `BackgroundJobStore` interface (Task 1)
-- Produces: Implemented `deferIfRunning()`, `retryDeferredClose()`, `clearDeferredClose()`
-
-- [ ] **Step 1: Add deferredIdleCloses tracking to coordinator**
-
-```typescript
-// In src/utils/background-job-coordinator.ts, add to class properties:
-
-  // Stores session IDs (which equal task IDs) awaiting close after background job completes
-  private readonly deferredIdleCloses = new Set<string>();
-```
-
-- [ ] **Step 2: Implement deferIfRunning method**
-
-```typescript
-// In src/utils/background-job-coordinator.ts, add method:
-
-  /**
-   * Evaluate close policy. Returns true if session should close now.
-   * Mutates deferred state: adds to deferred set if running, removes if not.
-   */
-  deferIfRunning(sessionId: string): boolean {
-    if (!this.board.isRunning(sessionId)) {
-      this.deferredIdleCloses.delete(sessionId);
-      return true;
-    }
-    this.deferredIdleCloses.add(sessionId);
-    return false;
-  }
-```
-
-- [ ] **Step 3: Implement retryDeferredClose method**
-
-```typescript
-// In src/utils/background-job-coordinator.ts, add method:
-
-  /**
-   * Retry closing a deferred session. Called when a background job completes.
-   * Returns true if the session should now close.
-   */
-  retryDeferredClose(sessionId: string): boolean {
-    if (!this.deferredIdleCloses.has(sessionId)) return false;
-    return this.deferIfRunning(sessionId);
-  }
-```
-
-- [ ] **Step 4: Implement clearDeferredClose method**
-
-```typescript
-// In src/utils/background-job-coordinator.ts, add method:
-
-  /**
-   * Clear deferred close state for a session being deleted.
-   */
-  clearDeferredClose(sessionId: string): void {
-    this.deferredIdleCloses.delete(sessionId);
-  }
-```
-
-- [ ] **Step 5: Update handleTerminalState to notify listeners**
-
-```typescript
-// In src/utils/background-job-coordinator.ts, update handleTerminalState:
-
-  private handleTerminalState(taskID: string): void {
-    // Re-check board state to handle races
-    const state = this.board.getState(taskID);
-    if (state === undefined) return;
-
-    // Check if this session should now close
-    if (this.retryDeferredClose(taskID)) {
-      // Notify listeners that session should close
-      for (const listener of this.terminalStateListeners) {
-        listener(taskID);
-      }
-    }
-  }
-```
-
-- [ ] **Step 6: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: PASS
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add src/utils/background-job-coordinator.ts
-git commit -m "feat: implement lifecycle policy in BackgroundJobCoordinator"
-```
-
----
-
-### Task 3: Write coordinator tests
-
-**Files:**
-- Create: `src/utils/background-job-coordinator.test.ts`
-
-**Interfaces:**
-- Consumes: BackgroundJobCoordinator (Task 2)
-
-- [ ] **Step 1: Create test file with mock board**
-
-```typescript
-// In src/utils/background-job-coordinator.test.ts:
-
-import { describe, expect, mock, test } from 'bun:test';
-import { BackgroundJobCoordinator } from './background-job-coordinator';
-
-function createMockBoard(isRunning = false) {
-  return {
-    isRunning: mock(() => isRunning),
-    getState: mock(() => (isRunning ? 'running' : 'completed')),
-    addTerminalStateListener: mock(() => {}),
-    removeTerminalStateListener: mock(() => {}),
-    // ... other methods as needed
-  } as any;
-}
-```
-
-- [ ] **Step 2: Test deferIfRunning returns false when job is running**
-
-```typescript
-test('deferIfRunning returns false when job is running', () => {
-  const board = createMockBoard(true);
-  const coordinator = new BackgroundJobCoordinator(board);
-  expect(coordinator.deferIfRunning('ses_123')).toBe(false);
-});
-```
-
-- [ ] **Step 3: Test deferIfRunning returns true when job is not running**
-
-```typescript
-test('deferIfRunning returns true when job is not running', () => {
-  const board = createMockBoard(false);
-  const coordinator = new BackgroundJobCoordinator(board);
-  expect(coordinator.deferIfRunning('ses_123')).toBe(true);
-});
-```
-
-- [ ] **Step 4: Test retryDeferredClose returns false when not in deferred set**
-
-```typescript
-test('retryDeferredClose returns false when not in deferred set', () => {
-  const board = createMockBoard(false);
-  const coordinator = new BackgroundJobCoordinator(board);
-  expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
-});
-```
-
-- [ ] **Step 5: Test retryDeferredClose calls deferIfRunning internally**
-
-```typescript
-test('retryDeferredClose returns true after job completes', () => {
-  const board = createMockBoard(true);
-  const coordinator = new BackgroundJobCoordinator(board);
-
-  // First call defers (job running)
-  expect(coordinator.deferIfRunning('ses_123')).toBe(false);
-
-  // Now simulate job completion
-  board.isRunning.mockReturnValue(false);
-  expect(coordinator.retryDeferredClose('ses_123')).toBe(true);
-});
-```
-
-- [ ] **Step 6: Test clearDeferredClose removes from set**
-
-```typescript
-test('clearDeferredClose removes from deferred set', () => {
-  const board = createMockBoard(true);
-  const coordinator = new BackgroundJobCoordinator(board);
-
-  coordinator.deferIfRunning('ses_123');
-  coordinator.clearDeferredClose('ses_123');
-
-  // Now retryDeferredClose should return false (not in set)
-  board.isRunning.mockReturnValue(false);
-  expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
-});
-```
-
-- [ ] **Step 7: Test handleTerminalState notifies listeners when retryDeferredClose returns true**
-
-```typescript
-test('handleTerminalState notifies listeners when retryDeferredClose returns true', () => {
-  const board = createMockBoard(true);
-  const coordinator = new BackgroundJobCoordinator(board);
-  const listener = mock(() => {});
-
-  coordinator.addTerminalStateListener(listener);
-
-  // Defer the session
-  coordinator.deferIfRunning('ses_123');
-
-  // Simulate terminal state notification from board
-  board.getState.mockReturnValue('completed');
-  board.isRunning.mockReturnValue(false);
-
-  // Trigger handleTerminalState via board's listener callback
-  const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
-  boardListener?.('ses_123');
-
-  expect(listener).toHaveBeenCalledWith('ses_123');
-});
-```
-
-- [ ] **Step 8: Test handleTerminalState does not notify when retryDeferredClose returns false**
-
-```typescript
-test('handleTerminalState does not notify when not in deferred set', () => {
-  const board = createMockBoard(false);
-  const coordinator = new BackgroundJobCoordinator(board);
-  const listener = mock(() => {});
-
-  coordinator.addTerminalStateListener(listener);
-
-  // Simulate terminal state notification without deferring first
-  board.getState.mockReturnValue('completed');
-  const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
-  boardListener?.('ses_123');
-
-  expect(listener).not.toHaveBeenCalled();
-});
-```
-
-- [ ] **Step 9: Run tests**
-
-Run: `bun test src/utils/background-job-coordinator.test.ts`
-Expected: PASS
-
-- [ ] **Step 10: Commit**
-
-```bash
-git add src/utils/background-job-coordinator.test.ts
-git commit -m "test: add BackgroundJobCoordinator lifecycle tests"
-```
-
----
-
-### Task 4: Update BackgroundJobBoard to satisfy interface
-
-**Files:**
-- Modify: `src/utils/background-job-board.ts`
-
-**Interfaces:**
-- Consumes: `BackgroundJobStore` interface (Task 1)
-- Produces: Implemented stubs
-
-- [ ] **Step 1: Add stub implementations to BackgroundJobBoard**
-
-```typescript
-// In src/utils/background-job-board.ts, add methods:
-
-  /**
-   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
-   * Returns false (safe default: don't close) if accidentally called.
-   */
-  deferIfRunning(_sessionId: string): boolean {
-    log('[background-job-board] WARN: deferIfRunning called on board, not coordinator');
-    return false;  // ponytail: safe default - don't close
-  }
-
-  /**
-   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
-   * Returns false (don't close) if accidentally called.
-   */
-  retryDeferredClose(_sessionId: string): boolean {
-    log('[background-job-board] WARN: retryDeferredClose called on board, not coordinator');
-    return false;
-  }
-
-  /**
-   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
-   */
-  clearDeferredClose(_sessionId: string): void {
-    log('[background-job-board] WARN: clearDeferredClose called on board, not coordinator');
-  }
-```
-
-- [ ] **Step 2: Add log import if not present**
-
-```typescript
-// In src/utils/background-job-board.ts, check if log is imported.
-// If not, add:
-import { log } from './logger';
-```
-
-- [ ] **Step 3: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: PASS
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/utils/background-job-board.ts
-git commit -m "feat: add stub lifecycle methods to BackgroundJobBoard"
-```
-
----
-
-### Task 5: Update MultiplexerSessionManager to use coordinator
-
-**Files:**
-- Modify: `src/multiplexer/session-manager.ts`
-
-**Interfaces:**
-- Consumes: `BackgroundJobReader` with `deferIfRunning()`, `clearDeferredClose()`
-- Produces: Simplified `closeSession()` that queries coordinator
-
-- [ ] **Step 1: Update BackgroundJobReader interface**
-
-```typescript
-// In src/multiplexer/session-manager.ts, update interface:
-
-interface BackgroundJobReader {
-  getState(sessionId: string): BackgroundJobState | undefined;
-  isRunning(sessionId: string): boolean;
-  deferIfRunning(sessionId: string): boolean;
-  clearDeferredClose(sessionId: string): void;
-}
-```
-
-- [ ] **Step 2: Remove deferredIdleCloses from SharedSessionState**
-
-```typescript
-// In src/multiplexer/session-manager.ts, remove from SharedSessionState interface:
-
-  // deferredIdleCloses: Set<string>;  // DELETE THIS LINE
-```
-
-- [ ] **Step 3: Remove deferredIdleCloses from getSharedState and resetMultiplexerSessionManagerState**
-
-```typescript
-// In getSharedState(), remove:
-  // deferredIdleCloses: new Set(),  // DELETE THIS LINE
-
-// In resetMultiplexerSessionManagerState(), remove:
-  // state.deferredIdleCloses.clear();  // DELETE THIS LINE
-```
-
-- [ ] **Step 4: Remove deferredIdleCloses from class properties**
-
-```typescript
-// In MultiplexerSessionManager class, remove:
-  // private deferredIdleCloses: SharedSessionState['deferredIdleCloses'];  // DELETE THIS LINE
-
-// In constructor, remove:
-  // this.deferredIdleCloses = sharedState.deferredIdleCloses;  // DELETE THIS LINE
-```
-
-- [ ] **Step 5: Update closeSession deleted block**
-
-```typescript
-// In closeSession method, replace the deleted block (lines 420-423):
-
-// OLD:
-    if (reason === 'deleted') {
-      this.knownSessions.delete(sessionId);
-      this.deferredIdleCloses.delete(sessionId);
-    }
-
-// NEW:
-    if (reason === 'deleted') {
-      this.knownSessions.delete(sessionId);
-      this.backgroundJobBoard?.clearDeferredClose(sessionId);
-    }
-```
-
-- [ ] **Step 6: Update closeSession idle check**
-
-```typescript
-// In closeSession method, replace the isRunningBackgroundJob check:
-
-// OLD:
-    if (reason === 'idle' && this.isRunningBackgroundJob(sessionId)) {
-      this.deferredIdleCloses.add(sessionId);
-      log(
-        '[multiplexer-session-manager] close skipped; background job running',
-        {
-          instanceId: this.instanceId,
-          sessionId,
-          paneId: tracked.paneId,
-          reason,
-          backgroundJobState: this.backgroundJobState(sessionId),
-        },
-      );
-      return;
-    }
-
-    this.deferredIdleCloses.delete(sessionId);
-
-// NEW:
-    if (reason === 'idle' && !this.shouldCloseNow(sessionId)) {
-      log(
-        '[multiplexer-session-manager] close skipped; background job running',
-        {
-          instanceId: this.instanceId,
-          sessionId,
-          paneId: tracked.paneId,
-          reason,
-          backgroundJobState: this.backgroundJobState(sessionId),
-        },
-      );
-      return;
-    }
-```
-
-- [ ] **Step 7: Add shouldCloseNow helper method**
-
-```typescript
-// In MultiplexerSessionManager class, add method:
-
-  private shouldCloseNow(sessionId: string): boolean {
-    return this.backgroundJobBoard?.deferIfRunning(sessionId) ?? true;
-  }
-```
-
-- [ ] **Step 8: Remove retryDeferredIdleClose method**
-
-```typescript
-// In MultiplexerSessionManager class, DELETE the retryDeferredIdleClose method:
-
-  // async retryDeferredIdleClose(sessionId: string): Promise<void> {  // DELETE
-  //   if (!this.enabled) return;  // DELETE
-  //   if (!this.deferredIdleCloses.has(sessionId)) return;  // DELETE
-  //   await this.closeSession(sessionId, 'idle');  // DELETE
-  // }  // DELETE
-```
-
-- [ ] **Step 9: Update onSessionDeleted to clear via coordinator**
-
-```typescript
-// In onSessionDeleted method, replace:
-    this.deferredIdleCloses.delete(sessionId);
-
-// WITH:
-    this.backgroundJobBoard?.clearDeferredClose(sessionId);
-```
-
-- [ ] **Step 10: Update onSessionStatus to clear via coordinator**
-
-```typescript
-// In onSessionStatus method, replace (line 293):
-        this.deferredIdleCloses.delete(sessionId);
-
-// WITH:
-        this.backgroundJobBoard?.clearDeferredClose(sessionId);
-```
-
-- [ ] **Step 11: Update pollSessions to clear via coordinator**
-
-```typescript
-// In pollSessions method, replace (line 377):
-          this.deferredIdleCloses.delete(sessionId);
-
-// WITH:
-          this.backgroundJobBoard?.clearDeferredClose(sessionId);
-```
-
-- [ ] **Step 12: Update respawnIfKnown to clear via coordinator**
-
-```typescript
-// In respawnIfKnown method, replace (line 589):
-      this.deferredIdleCloses.delete(sessionId);
-
-// WITH:
-      this.backgroundJobBoard?.clearDeferredClose(sessionId);
-```
-
-- [ ] **Step 13: Update cleanup to clear via coordinator**
-
-```typescript
-// In cleanup method, replace (line 662):
-    this.deferredIdleCloses.clear();
-
-// WITH:
-    // ponytail: deferred state lives in coordinator, not here
-    // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
-```
-
-- [ ] **Step 14: Remove isRunningBackgroundJob method**
-
-```typescript
-// In MultiplexerSessionManager class, DELETE the isRunningBackgroundJob method:
-
-  // private isRunningBackgroundJob(sessionId: string): boolean {  // DELETE
-  //   return this.backgroundJobBoard?.isRunning(sessionId) ?? false;  // DELETE
-  // }  // DELETE
-```
-
-- [ ] **Step 15: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: PASS
-
-- [ ] **Step 16: Commit**
-
-```bash
-git add src/multiplexer/session-manager.ts
-git commit -m "feat: multiplexer queries coordinator for close decisions"
-```
-
----
-
-### Task 6: Update index.ts wiring
-
-**Files:**
-- Modify: `src/index.ts`
-
-**Interfaces:**
-- Consumes: Coordinator with `addTerminalStateListener`, MultiplexerSessionManager with `closeSession`
-
-- [ ] **Step 1: Update terminalStateListener to call closeSession directly**
-
-```typescript
-// In src/index.ts, replace:
-
-    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
-      void multiplexerSessionManager.retryDeferredIdleClose(taskID);
-    });
-
-// WITH:
-
-    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
-      void multiplexerSessionManager.closeSession(taskID, 'idle');
-    });
-```
-
-Note: `closeSession` is private. We need to either:
-- (a) Make it public, or
-- (b) Add a public `closeSessionFromCoordinator(taskID: string)` method, or
-- (c) Keep the subscription in index.ts but call a new public method
-
-Option (b) is cleanest:
-
-```typescript
-// In MultiplexerSessionManager, add method:
-
-  async closeSessionFromCoordinator(taskID: string): Promise<void> {
-    if (!this.enabled) return;
-    await this.closeSession(taskID, 'idle');
-  }
-```
-
-Then in index.ts:
-
-```typescript
-    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
-      void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
-    });
-```
-
-- [ ] **Step 2: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: PASS
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/index.ts src/multiplexer/session-manager.ts
-git commit -m "feat: update wiring to use coordinator lifecycle"
-```
-
----
-
-### Task 7: Update tests
-
-**Files:**
-- Modify: `src/multiplexer/session-manager.test.ts`
-
-**Interfaces:**
-- Consumes: Updated MultiplexerSessionManager API
-
-- [ ] **Step 1: Update test setup to use BackgroundJobReader mock**
-
-```typescript
-// In session-manager.test.ts, add mock:
-
-const mockBackgroundJobBoard = {
-  isRunning: mock(() => false),
-  getState: mock(() => undefined),
-  deferIfRunning: mock(() => true),
-  retryDeferredClose: mock(() => false),
-  clearDeferredClose: mock(() => {}),
-};
-```
-
-- [ ] **Step 2: Run tests**
-
-Run: `bun test src/multiplexer/session-manager.test.ts`
-Expected: PASS
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/multiplexer/session-manager.test.ts
-git commit -m "test: update session manager tests for coordinator lifecycle"
-```
-
----
-
-### Task 8: Run full test suite and verify
-
-**Files:** None (verification only)
-
-- [ ] **Step 1: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: PASS
-
-- [ ] **Step 2: Run linter**
-
-Run: `bun run check:ci`
-Expected: PASS
-
-- [ ] **Step 3: Run full test suite**
-
-Run: `bun test`
-Expected: PASS (1367+ tests)
-
-- [ ] **Step 4: Build**
-
-Run: `bun run build`
-Expected: PASS
-
----
-
-### Task 9: Final commit and push
-
-- [ ] **Step 1: Stage all changes**
-
-```bash
-git add -A
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git commit -m "feat: centralize lifecycle policy in BackgroundJobCoordinator
-
-- Move deferredIdleCloses tracking from multiplexer to coordinator
-- Coordinator owns deferIfRunning(), retryDeferredClose(), clearDeferredClose()
-- Multiplexer queries coordinator before closing panes
-- Subscription wiring stays in index.ts (avoids multi-instance issues)
-- Type-level single-writer contract via BackgroundJobStore interface
-- Board stubs return safe defaults (false) if called directly
-- Added coordinator lifecycle tests
-
-Closes #677"
-```
-
-- [ ] **Step 3: Push**
-
-```bash
-git push origin feature/background-job-coordinator
-```

+ 0 - 801
docs/superpowers/plans/2026-07-06-hook-registry-session-lifecycle.md

@@ -1,801 +0,0 @@
-# HookRegistry + SessionLifecycle Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Eliminate manual hook wiring, scattered session.deleted cleanup, and the reversed-priority session ID bug.
-
-**Architecture:** Three-phase build: (1) `extractSessionId` utility replacing 8 duplicated sites, (2) `SessionLifecycle` coordinator owning cleanup callbacks + signaling channel with timestamp TTL, (3) `HookRegistry` for all async hook dispatch.
-
-**Tech Stack:** TypeScript, Bun, Biome
-
-## Global Constraints
-
-- Line width: 80 chars, 2-space indent, trailing commas
-- No explicit `any` (linter warning)
-- Biome organizes imports, run `bun run check:ci` before commit
-- Commit after every green test run, wait for user "proceed" at each task boundary
-
----
-## File Structure
-
-### New files
-| File | Responsibility |
-|------|---------------|
-| `src/utils/extract-session-id.ts` | `extractSessionId(info, sessionID)` — priority `info?.id ?? sessionID` |
-| `src/utils/extract-session-id.test.ts` | Tests for priority, null/undefined, edge cases |
-| `src/hooks/session-lifecycle.ts` | `SessionLifecycle` class — cleanup callback registry + signaling channel with timestamp TTL |
-| `src/hooks/session-lifecycle.test.ts` | Tests for cleanup registration/dispatch, signaling, TTL expiry |
-| `src/hooks/hook-registry.ts` | `HookRegistry` class — ordered dispatcher with late-registration warning |
-| `src/hooks/hook-registry.test.ts` | Tests for registration order, late-registration, no-op dispatch |
-
-### Modified files
-| File | Changes |
-|------|---------|
-| `src/index.ts` | Delete `let` hook declarations, use `const` inside try, register with HookRegistry, replace manual dispatch with `registry.dispatch()`, wire SessionLifecycle for session.deleted |
-| `src/hooks/post-file-tool-nudge/index.ts` | Accept `SessionLifecycle`, delegate Sets to coordinator, use `extractSessionId`, remove `event()` method, remove `hasPendingSession` export |
-| `src/hooks/phase-reminder/index.ts` | Accept `SessionLifecycle` param, import `hasPendingSession` from `session-lifecycle` |
-| `src/hooks/task-session-manager/index.ts` | Use `extractSessionId`, register cleanup callback with coordinator |
-| `src/hooks/foreground-fallback/index.ts` | Accept `SessionLifecycle`, register cleanup callback, use `extractSessionId` |
-| `src/hooks/post-file-tool-nudge/index.test.ts` | Pass coordinator to factory |
-| `src/hooks/phase-reminder/index.test.ts` | Pass coordinator to factory, update `hasPendingSession` import |
-| `src/hooks/task-session-manager/index.test.ts` | Verify cleanup through coordinator |
-| `src/multiplexer/session-manager.ts` | Use `extractSessionId` (line 610) |
-
----
-### Task 0: Baseline test run
-
-- [ ] **Step 1: Run baseline tests**
-
-Run: `bun test`
-Expected: 1367 pass, 0 fail
-
-- [ ] **Step 2: Record output reference**
-
----
-### Task 1: `src/utils/extract-session-id.ts`
-
-**Files:**
-- Create: `src/utils/extract-session-id.ts`
-- Create: `src/utils/extract-session-id.test.ts`
-- Modify: `src/index.ts` (lines 889, 897)
-- Modify: `src/multiplexer/session-manager.ts` (line 610)
-- Modify: `src/hooks/task-session-manager/index.ts` (lines 582, 635, 659, 693)
-- Modify: `src/hooks/foreground-fallback/index.ts` (line 236)
-- Modify: `src/hooks/post-file-tool-nudge/index.ts` (line 77 — reversed priority)
-
-**Interfaces:**
-- Produces: `export function extractSessionId(info: { id?: string } | undefined | null, sessionID: string | undefined | null): string | undefined`
-
-- [ ] **Step 1: Create the utility**
-
-```typescript
-export function extractSessionId(
-  info: { id?: string } | undefined | null,
-  sessionID: string | undefined | null,
-): string | undefined {
-  return info?.id ?? sessionID;
-}
-```
-
-- [ ] **Step 2: Create tests**
-
-```typescript
-import { describe, expect, test } from 'bun:test';
-import { extractSessionId } from './extract-session-id';
-
-describe('extractSessionId', () => {
-  test('prefers info.id over sessionID', () => {
-    expect(extractSessionId({ id: 'i' }, 's')).toBe('i');
-  });
-
-  test('falls back to sessionID when info.id missing', () => {
-    expect(extractSessionId({}, 's')).toBe('s');
-    expect(extractSessionId({ id: undefined }, 's')).toBe('s');
-  });
-
-  test('returns undefined when both missing', () => {
-    expect(extractSessionId(undefined, undefined)).toBeUndefined();
-    expect(extractSessionId(null, null)).toBeUndefined();
-    expect(extractSessionId({}, undefined)).toBeUndefined();
-  });
-
-  test('handles null info', () => {
-    expect(extractSessionId(null, 's')).toBe('s');
-  });
-});
-```
-
-- [ ] **Step 3: Run test to verify it fails**
-
-Run: `bun test src/utils/extract-session-id.test.ts`
-Expected: FAIL (module not found)
-
-- [ ] **Step 4: Replace all 8 manual extraction sites**
-
-Each `props?.info?.id ?? props?.sessionID` → `extractSessionId(props?.info, props?.sessionID)`.
-
-Fix the reversed-priority site at `src/hooks/post-file-tool-nudge/index.ts:77`:
-```typescript
-input.event.properties?.sessionID ?? input.event.properties?.info?.id
-```
-→
-```typescript
-extractSessionId(
-  input.event.properties?.info,
-  input.event.properties?.sessionID,
-)
-```
-
-Deduplicate the two adjacent `session.deleted` blocks in `src/index.ts:885-905` into one block using `extractSessionId`.
-
-- [ ] **Step 5: Run all tests**
-
-Run: `bun test`
-Expected: Same count as baseline, all pass
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add src/utils/extract-session-id.ts src/utils/extract-session-id.test.ts src/index.ts src/multiplexer/session-manager.ts src/hooks/task-session-manager/index.ts src/hooks/foreground-fallback/index.ts src/hooks/post-file-tool-nudge/index.ts
-bun run check:ci
-git commit -m "feat: add extractSessionId utility, fix reversed-priority session ID bug"
-```
-
----
-### Task 2: SessionLifecycle coordinator class + tests
-
-**Files:**
-- Create: `src/hooks/session-lifecycle.ts`
-- Create: `src/hooks/session-lifecycle.test.ts`
-
-**Interfaces:**
-- Produces:
-```typescript
-export class SessionLifecycle {
-  static readonly PENDING_TTL_MS: number;
-  constructor(log: (msg: string, meta?: Record<string, unknown>) => void);
-  onSessionDeleted(callback: (sessionId: string) => void): void;
-  dispatchSessionDeleted(sessionId: string): void;
-  markPending(sessionId: string): void;
-  /** Returns true only once per markPending call. */
-  consumePending(sessionId: string): boolean;
-  hasPendingSession(sessionId: string): boolean;
-  clearSession(sessionId: string): void;
-}
-```
-
-- [ ] **Step 1: Create the class**
-
-```typescript
-// src/hooks/session-lifecycle.ts
-export class SessionLifecycle {
-  static readonly PENDING_TTL_MS = 5 * 60 * 1000;
-
-  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
-  #pendingSessionIds = new Set<string>();
-  #everPendingSessionIds = new Set<string>();
-  #pendingTimestamps = new Map<string, number>();
-  #log: (msg: string, meta?: Record<string, unknown>) => void;
-
-  constructor(
-    log: (msg: string, meta?: Record<string, unknown>) => void,
-  ) {
-    this.#log = log;
-  }
-
-  onSessionDeleted(callback: (sessionId: string) => void): void {
-    this.#cleanupCallbacks.push(callback);
-  }
-
-  dispatchSessionDeleted(sessionId: string): void {
-    for (const cb of this.#cleanupCallbacks) {
-      try {
-        cb(sessionId);
-      } catch (error) {
-        this.#log(
-          `[session-lifecycle] cleanup callback failed for session ${sessionId}`,
-          { error },
-        );
-      }
-    }
-  }
-
-  markPending(sessionId: string): void {
-    this.#pendingSessionIds.add(sessionId);
-    this.#everPendingSessionIds.add(sessionId);
-    this.#pendingTimestamps.set(sessionId, Date.now());
-  }
-
-  /** Atomic — only one caller gets true per markPending call. */
-  consumePending(sessionId: string): boolean {
-    const had = this.#pendingSessionIds.has(sessionId);
-    this.#pendingSessionIds.delete(sessionId);
-    this.#pendingTimestamps.delete(sessionId);
-    return had;
-  }
-
-  hasPendingSession(sessionId: string): boolean {
-    const ts = this.#pendingTimestamps.get(sessionId);
-    if (ts && Date.now() - ts > SessionLifecycle.PENDING_TTL_MS) {
-      this.#pendingTimestamps.delete(sessionId);
-      this.#pendingSessionIds.delete(sessionId);
-      return false;
-    }
-    return (
-      this.#everPendingSessionIds.has(sessionId)
-      && !this.#pendingSessionIds.has(sessionId)
-    );
-  }
-
-  clearSession(sessionId: string): void {
-    this.#pendingSessionIds.delete(sessionId);
-    this.#everPendingSessionIds.delete(sessionId);
-    this.#pendingTimestamps.delete(sessionId);
-  }
-}
-```
-
-- [ ] **Step 2: Create tests**
-
-```typescript
-import { describe, expect, test } from 'bun:test';
-import { SessionLifecycle } from './session-lifecycle';
-
-const noop = () => {};
-
-describe('SessionLifecycle', () => {
-  test('dispatchSessionDeleted runs callbacks in order', () => {
-    const lc = new SessionLifecycle(noop);
-    const ran: string[] = [];
-    lc.onSessionDeleted((id) => ran.push(`a:${id}`));
-    lc.onSessionDeleted((id) => ran.push(`b:${id}`));
-    lc.dispatchSessionDeleted('s1');
-    expect(ran).toEqual(['a:s1', 'b:s1']);
-  });
-
-  test('dispatchSessionDeleted continues after callback error', () => {
-    const lc = new SessionLifecycle(() => {});
-    const ran: string[] = [];
-    lc.onSessionDeleted(() => { throw new Error('fail'); });
-    lc.onSessionDeleted((id) => ran.push(id));
-    lc.dispatchSessionDeleted('s1');
-    expect(ran).toEqual(['s1']);
-  });
-
-  test('consumePending is atomic', () => {
-    const lc = new SessionLifecycle(noop);
-    lc.markPending('s1');
-    expect(lc.consumePending('s1')).toBe(true);
-    expect(lc.consumePending('s1')).toBe(false);
-  });
-
-  test('hasPendingSession after consume', () => {
-    const lc = new SessionLifecycle(noop);
-    lc.markPending('s1');
-    lc.consumePending('s1');
-    expect(lc.hasPendingSession('s1')).toBe(true);
-  });
-
-  test('hasPendingSession false for unknown session', () => {
-    const lc = new SessionLifecycle(noop);
-    expect(lc.hasPendingSession('s1')).toBe(false);
-  });
-
-  test('clearSession removes all state', () => {
-    const lc = new SessionLifecycle(noop);
-    lc.markPending('s1');
-    lc.consumePending('s1');
-    lc.clearSession('s1');
-    expect(lc.hasPendingSession('s1')).toBe(false);
-  });
-});
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `bun test src/hooks/session-lifecycle.test.ts`
-Expected: PASS
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/hooks/session-lifecycle.ts src/hooks/session-lifecycle.test.ts
-bun run check:ci
-git commit -m "feat: add SessionLifecycle coordinator"
-```
-
----
-### Task 3: Update hooks to use SessionLifecycle + extractSessionId
-
-**Files:**
-- Modify: `src/hooks/post-file-tool-nudge/index.ts`
-- Modify: `src/hooks/post-file-tool-nudge/index.test.ts`
-- Modify: `src/hooks/phase-reminder/index.ts`
-- Modify: `src/hooks/phase-reminder/index.test.ts`
-- Modify: `src/hooks/task-session-manager/index.ts`
-- Modify: `src/hooks/task-session-manager/index.test.ts`
-- Modify: `src/hooks/foreground-fallback/index.ts`
-
-**Interfaces:**
-- Consumes: `SessionLifecycle` from `../session-lifecycle`, `extractSessionId` from `../../utils/extract-session-id`
-
-- [ ] **Step 1: Update post-file-tool-nudge/index.ts**
-
-Remove module-scoped Sets, `hasPendingSession` export, and `event()` method (only handled session.deleted). Accept `coordinator?: SessionLifecycle` in factory options. Cleanup is handled via coordinator callback. Use `coordinator.markPending()` and `coordinator.consumePending()` instead of module-scoped Sets.
-
-```typescript
-import { PHASE_REMINDER } from '../../config/constants';
-import type { SessionLifecycle } from '../session-lifecycle';
-
-const FILE_TOOLS = new Set(['Read', 'read', 'Write', 'write']);
-
-interface PostFileToolNudgeOptions {
-  shouldInject?: (sessionID: string) => boolean;
-  coordinator?: SessionLifecycle;
-}
-
-export function createPostFileToolNudgeHook(
-  options: PostFileToolNudgeOptions = {},
-) {
-  const { coordinator } = options;
-
-  if (coordinator) {
-    coordinator.onSessionDeleted(
-      (sid) => coordinator.clearSession(sid),
-    );
-  }
-
-  return {
-    'tool.execute.after': async (
-      input: { tool: string; sessionID?: string; callID?: string },
-    ): Promise<void> => {
-      if (!FILE_TOOLS.has(input.tool) || !input.sessionID) return;
-      coordinator?.markPending(input.sessionID);
-    },
-    'experimental.chat.system.transform': async (
-      input: { sessionID?: string },
-      output: { system: string[] },
-    ): Promise<void> => {
-      if (!input.sessionID || !coordinator?.consumePending(input.sessionID)) {
-        return;
-      }
-      if (options.shouldInject && !options.shouldInject(input.sessionID)) {
-        return;
-      }
-      output.system.push(PHASE_REMINDER);
-    },
-  };
-}
-```
-
-Note `_output` param removed from `tool.execute.after` since it was unused (was `_output: unknown`).
-
-- [ ] **Step 2: Update phase-reminder/index.ts**
-
-Accept `coordinator?: SessionLifecycle` parameter. Import `hasPendingSession` from the coordinator instead of `../post-file-tool-nudge`. Remove the `import { hasPendingSession }` line.
-
-```typescript
-import type { SessionLifecycle } from '../session-lifecycle';
-
-export function createPhaseReminderHook(
-  coordinator?: SessionLifecycle,
-) {
-  return {
-    'experimental.chat.messages.transform': async (
-      _input: Record<string, never>,
-      output: { messages?: unknown },
-    ): Promise<void> => {
-      // ... existing logic ...
-      if (sessionId && coordinator?.hasPendingSession(sessionId)) {
-        return;
-      }
-      // ... rest unchanged ...
-    },
-  };
-}
-```
-
-- [ ] **Step 3: Update task-session-manager/index.ts**
-
-All 4 `info?.id ?? sessionID` sites are already replaced with `extractSessionId` (Task 1). The `session.deleted` case in `.event()` (lines 691-721) is replaced by registering a cleanup callback with the coordinator. Add `coordinator?: SessionLifecycle` to factory options.
-
-```typescript
-interface TaskSessionManagerOptions {
-  // ... existing options ...
-  coordinator?: SessionLifecycle;
-}
-```
-
-Register cleanup in the factory:
-```typescript
-if (options.coordinator) {
-  options.coordinator.onSessionDeleted((sessionId) => {
-    backgroundJobBoard.drop(sessionId);
-    backgroundJobBoard.clearParent(sessionId);
-    terminalJobsInjectedByParent.delete(sessionId);
-    taskContextTracker.clearSession(sessionId);
-    taskContextTracker.prune(backgroundJobBoard);
-    pendingCallTracker.clearSession(sessionId);
-  });
-}
-```
-
-The `session.deleted` case in `.event()` is reduced to just logging (no cleanup ops):
-```typescript
-if (input.event.type !== 'session.deleted') return;
-const sessionId = extractSessionId(
-  input.event.properties?.info,
-  input.event.properties?.sessionID,
-);
-if (!sessionId) return;
-log('[task-session-manager] session.deleted observed', { sessionID: sessionId });
-return;
-```
-
-- [ ] **Step 4: Update foreground-fallback/index.ts**
-
-Accept `coordinator?: SessionLifecycle` in the constructor. Register cleanup callbacks. Use `extractSessionId` (already done in Task 1).
-
-```typescript
-constructor(
-  // ... existing params ...
-  private coordinator?: SessionLifecycle,
-) {
-  if (coordinator) {
-    coordinator.onSessionDeleted((id) => {
-      this.sessionModel.delete(id);
-      this.sessionAgent.delete(id);
-      this.sessionTried.delete(id);
-      this.inProgress.delete(id);
-      this.lastTrigger.delete(id);
-      this.lastTriggerModel.delete(id);
-      this.sessionRetries.delete(id);
-    });
-  }
-  // ... rest of constructor ...
-}
-```
-
-The `session.deleted` case in `handleEvent` (lines 226-247) is reduced to logging:
-```typescript
-case 'session.deleted': {
-  const props = event.properties as
-    | { sessionID?: string; info?: { id?: string } }
-    | undefined;
-  const id = extractSessionId(props?.info, props?.sessionID);
-  if (id) {
-    log('[foreground-fallback] session.deleted observed', { sessionID: id });
-  }
-  break;
-}
-```
-
-- [ ] **Step 5: Update post-file-tool-nudge tests**
-
-Each test that creates hooks with `createPostFileToolNudgeHook()` now needs a shared coordinator:
-
-```typescript
-import { SessionLifecycle } from '../session-lifecycle';
-
-test('records pending session on Read tool', async () => {
-  const coordinator = new SessionLifecycle(() => {});
-  const hook = createPostFileToolNudgeHook({ coordinator });
-  // ... rest same ...
-});
-```
-
-The "composed" test (line 153) needs a coordinator shared between both hooks:
-
-```typescript
-test('composed: phase-reminder skips when post-file-tool-nudge handles system', async () => {
-  const coordinator = new SessionLifecycle(() => {});
-  const nudgeHook = createPostFileToolNudgeHook({ coordinator });
-  const phaseHook = createPhaseReminderHook(coordinator);
-  // ... rest same ...
-});
-```
-
-- [ ] **Step 6: Update phase-reminder tests**
-
-Tests that call `createPhaseReminderHook()` now pass the coordinator:
-```typescript
-const coordinator = new SessionLifecycle(() => {});
-const phaseHook = createPhaseReminderHook(coordinator);
-```
-
-Import changes: `hasPendingSession` no longer needs to be imported from `../post-file-tool-nudge` — it's on the coordinator instance.
-
-- [ ] **Step 7: Update task-session-manager tests**
-
-If any test verifies cleanup via `.event()` with `session.deleted`, it now needs to verify cleanup through the coordinator callback instead. The `event()` method no longer performs cleanup ops.
-
-- [ ] **Step 8: Run all tests**
-
-Run: `bun test`
-Expected: All pass
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add src/hooks/post-file-tool-nudge/ src/hooks/phase-reminder/ src/hooks/task-session-manager/ src/hooks/foreground-fallback/
-bun run check:ci
-git commit -m "refactor: migrate hooks to SessionLifecycle coordinator"
-```
-
----
-### Task 4: Wire SessionLifecycle into src/index.ts
-
-**Files:**
-- Modify: `src/index.ts`
-
-- [ ] **Step 1: Instantiate SessionLifecycle before hook factories**
-
-Inside the `try` block, before any hook factory calls:
-```typescript
-const sessionLifecycle = new SessionLifecycle(log);
-```
-
-- [ ] **Step 2: Pass coordinator to hook factories**
-
-`postFileToolNudgeHook = createPostFileToolNudgeHook({
-  shouldInject: (sessionID) => sessionAgentMap.get(sessionID) === 'orchestrator',
-  coordinator: sessionLifecycle,
-});`
-
-`taskSessionManagerHook = createTaskSessionManagerHook(ctx, { /* ...existing... */, coordinator: sessionLifecycle });`
-
-`phaseReminderHook = createPhaseReminderHook(sessionLifecycle);`
-
-`ForegroundFallbackManager` constructor: add `sessionLifecycle` as a parameter.
-
-- [ ] **Step 3: Add session.deleted dispatch via coordinator**
-
-In the `event` handler, add a dispatch block for `session.deleted`:
-```typescript
-if (input.event.type === 'session.deleted') {
-  const props = input.event.properties as ...;
-  const sessionID = extractSessionId(props?.info, props?.sessionID);
-  if (sessionID) {
-    sessionLifecycle.dispatchSessionDeleted(sessionID);
-  }
-}
-```
-
-- [ ] **Step 4: Run tests**
-
-Run: `bun test`
-Expected: All pass
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add src/index.ts
-bun run check:ci
-git commit -m "feat: wire SessionLifecycle coordinator into plugin"
-```
-
----
-### Task 5: HookRegistry class + tests
-
-**Files:**
-- Create: `src/hooks/hook-registry.ts`
-- Create: `src/hooks/hook-registry.test.ts`
-
-**Interfaces:**
-- Produces:
-```typescript
-export class HookRegistry {
-  register(hookPoint: string, handler: (i: unknown, o: unknown) => Promise<void>): void;
-  dispatch(hookPoint: string, input: unknown, output: unknown): Promise<void>;
-  handlers(hookPoint: string): ReadonlyArray<(i: unknown, o: unknown) => Promise<void>>;
-}
-```
-
-- [ ] **Step 1: Create the class**
-
-```typescript
-export class HookRegistry {
-  #handlers = new Map<
-    string,
-    Array<(input: unknown, output: unknown) => Promise<void>>
-  >();
-  #firedHookPoints = new Set<string>();
-
-  register(
-    hookPoint: string,
-    handler: (input: unknown, output: unknown) => Promise<void>,
-  ): void {
-    if (this.#firedHookPoints.has(hookPoint)) {
-      console.warn(
-        `[hook-registry] "${hookPoint}" already dispatched; late registration may miss events`,
-      );
-    }
-    const group = this.#handlers.get(hookPoint);
-    if (group) {
-      group.push(handler);
-    } else {
-      this.#handlers.set(hookPoint, [handler]);
-    }
-  }
-
-  async dispatch(
-    hookPoint: string,
-    input: unknown,
-    output: unknown,
-  ): Promise<void> {
-    this.#firedHookPoints.add(hookPoint);
-    const group = this.#handlers.get(hookPoint);
-    if (!group) return;
-    for (const handler of group) {
-      await handler(input, output);
-    }
-  }
-
-  handlers(
-    hookPoint: string,
-  ): ReadonlyArray<(input: unknown, output: unknown) => Promise<void>> {
-    return this.#handlers.get(hookPoint) ?? [];
-  }
-}
-```
-
-- [ ] **Step 2: Create tests**
-
-```typescript
-import { describe, expect, test } from 'bun:test';
-import { HookRegistry } from './hook-registry';
-
-describe('HookRegistry', () => {
-  test('dispatch runs handlers in registration order', async () => {
-    const r = new HookRegistry();
-    const order: number[] = [];
-    r.register('test', async () => { order.push(1); });
-    r.register('test', async () => { order.push(2); });
-    await r.dispatch('test', {}, {});
-    expect(order).toEqual([1, 2]);
-  });
-
-  test('unregistered hook point is no-op', async () => {
-    const r = new HookRegistry();
-    await r.dispatch('none', {}, {});
-  });
-
-  test('handlers returns empty for unregistered point', () => {
-    const r = new HookRegistry();
-    expect(r.handlers('x')).toEqual([]);
-  });
-
-  test('dispatch passes input and output to handlers', async () => {
-    const r = new HookRegistry();
-    const captured: unknown[] = [];
-    r.register('test', async (i, o) => { captured.push(i, o); });
-    await r.dispatch('test', { a: 1 }, { b: 2 });
-    expect(captured).toEqual([{ a: 1 }, { b: 2 }]);
-  });
-});
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `bun test src/hooks/hook-registry.test.ts`
-Expected: PASS
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/hooks/hook-registry.ts src/hooks/hook-registry.test.ts
-bun run check:ci
-git commit -m "feat: add HookRegistry for ordered handler dispatch"
-```
-
----
-### Task 6: Wire HookRegistry into src/index.ts (biggest task)
-
-**Files:**
-- Modify: `src/index.ts`
-
-Goal: Replace manual `let` declarations + per-hook dispatch with `registry.dispatch()` calls.
-
-- [ ] **Step 1: Understand the current pattern**
-
-Currently the plugin function has:
-1. ~15 `let xHook: ReturnType<...>` declarations outside try (lines 138-153)
-2. Factory calls inside try (lines 271-322) that assign to those variables
-3. 5 dispatch blocks in the return object (lines 910-1186) that call hook methods individually
-
-- [ ] **Step 2: Convert pattern**
-
-Replace:
-```typescript
-let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
-// ... in try block ...
-phaseReminderHook = createPhaseReminderHook(sessionLifecycle);
-// ... in return block ...
-await phaseReminderHook['experimental.chat.messages.transform'](input, typedOutput);
-```
-
-With:
-```typescript
-// In try block:
-const phaseReminder = createPhaseReminderHook(sessionLifecycle);
-hookRegistry.register(
-  'experimental.chat.messages.transform',
-  (i, o) => phaseReminder['experimental.chat.messages.transform'](i, o as any),
-);
-// ... repeat for other hooks ...
-```
-
-Note: The `hookRegistry` is instantiated inside the try block. The return block only needs closure on `hookRegistry`, not on individual hook instances.
-
-- [ ] **Step 3: Map each hook point to its dispatches**
-
-| Hook point | Hooks that implement it |
-|---|---|
-| `experimental.chat.messages.transform` | taskSessionManager, phaseReminder, filterAvailableSkills |
-| `experimental.chat.system.transform` | postFileToolNudge |
-| `tool.execute.before` | applyPatch, taskSessionManager |
-| `tool.execute.after` | delegateTaskRetry, jsonErrorRecovery, postFileToolNudge, taskSessionManager |
-| `command.execute.before` | deepworkCommand, reflectCommand, loopCommand |
-| `event` | foregroundFallback, taskSessionManager (session.idle/status/error only — no longer session.deleted) |
-| `chat.headers` | chatHeaders (sync, stays manual) |
-
-- [ ] **Step 4: Replace each dispatch block in the return object**
-
-Each becomes:
-```typescript
-'experimental.chat.messages.transform':
-  (input, output) => hookRegistry.dispatch('experimental.chat.messages.transform', input, output),
-```
-
-Note: `event` handler is special — it still dispatches to non-hook consumers (multiplexer, companion, autoUpdateChecker, interview). Only the hook portions go through the registry.
-
-- [ ] **Step 5: Delete unused `let` declarations**
-
-Remove the hook variable `let` declarations from the outer scope (lines 138-153). Keep non-hook `let` declarations (managers, boards, tools).
-
-- [ ] **Step 6: Delete unused imports**
-
-Remove any `ReturnType<typeof createXHook>` from imports that are no longer used as types.
-
-- [ ] **Step 7: Run tests**
-
-Run: `bun test`
-Expected: All 1367+ pass
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add src/index.ts
-bun run check:ci
-git commit -m "refactor: wire HookRegistry, delete manual hook dispatching"
-```
-
----
-### Task 7: Final verification
-
-- [ ] **Step 1: Run full test suite**
-
-Run: `bun test`
-Expected: All pass, same count as baseline
-
-- [ ] **Step 2: Run typecheck**
-
-Run: `bun run typecheck`
-Expected: No errors
-
-- [ ] **Step 3: Run linter**
-
-Run: `bun run check:ci`
-Expected: No errors
-
-- [ ] **Step 4: Update codemap if needed**
-
-Check if `src/hooks/codemap.md` needs updating to reflect the new registry + coordinator architecture.
-
-- [ ] **Step 5: Final commit**
-
-```bash
-git add -A
-bun run check:ci
-git commit -m "chore: final cleanup after HookRegistry+SessionLifecycle migration"
-```

+ 0 - 232
docs/superpowers/specs/2026-07-06-hook-registry-session-lifecycle-design.md

@@ -1,232 +0,0 @@
-# HookRegistry + SessionLifecycle Coordinator
-
-**Category:** enhancement
-**Author:** mhenke
-**Date:** 2026-07-06
-**Issue:** #675
-**Status:** approved
-
-## Problem
-
-Four verified problems in the hooks architecture of `oh-my-opencode-slim`:
-
-1. **Manual wiring friction.** Adding a new hook requires touching 6-10 locations: export from `src/hooks/index.ts`, import in `src/index.ts`, variable declaration, factory call, and a dispatch call for each hook point. Verified at `src/index.ts:6-34` (imports), `138-153` (declarations), `271-322` (factory calls), `820-1186` (dispatch sites). 13 hooks currently exist; this friction scales linearly.
-
-2. **Scattered session.deleted cleanup.** Three hooks each implement their own cleanup:
-   - `task-session-manager`: 6 ops at `src/hooks/task-session-manager/index.ts:715-720`
-   - `foreground-fallback`: 7 ops at `src/hooks/foreground-fallback/index.ts:238-244`
-   - `post-file-tool-nudge`: 2 ops at `src/hooks/post-file-tool-nudge/index.ts:79-80`
-   A new stateful hook that forgets `session.deleted` leaks memory silently.
-
-3. **Reversed-priority session ID bug.** `info?.id ?? sessionID` is duplicated 8 times across 4 files. One location (`src/hooks/post-file-tool-nudge/index.ts:77`) uses the reversed priority `sessionID ?? info?.id`. During session transitions when both fields differ, this picks the wrong session ID, causing missed cleanup or stale pending state.
-
-4. **Module-scoped Sets with no TTL.** `post-file-tool-nudge` owns `pendingSessionIds` and `everPendingSessionIds` at module scope. `phase-reminder` imports `hasPendingSession` from `post-file-tool-nudge` (`src/hooks/phase-reminder/index.ts:10`). Consumption is a side effect of `.delete()`. If the handler throws or is skipped, the session stays pending forever.
-
-## Solution
-
-Three modules:
-
-### 1. `src/utils/extract-session-id.ts`
-
-Single function that replaces all 8 manual extractions:
-
-```typescript
-export function extractSessionId(
-  info: { id?: string } | undefined | null,
-  sessionID: string | undefined | null,
-): string | undefined {
-  return info?.id ?? sessionID;
-}
-```
-
-- Priority: `info?.id` wins over `sessionID` (matches the 7 correct locations).
-- Located in `src/utils/` because `src/multiplexer/session-manager.ts:610` also uses it.
-- Fixes the reversed-priority bug at `post-file-tool-nudge/index.ts:77`.
-- Also deduplicates the two adjacent `session.deleted` blocks in `src/index.ts:885-905`.
-
-### 2. `src/hooks/session-lifecycle.ts` — SessionLifecycle coordinator
-
-Two responsibilities:
-
-**Cleanup callback registry.** Stateful hooks register a callback instead of implementing their own `session.deleted` handler. The coordinator runs all registered callbacks when `dispatchSessionDeleted(sessionId)` is called. If a callback throws, the error is logged and the remaining callbacks still run — one failure does not block others.
-
-```typescript
-class SessionLifecycle {
-  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
-  #pendingSessionIds = new Set<string>();
-  #everPendingSessionIds = new Set<string>();
-  #pendingTimestamps = new Map<string, number>();
-
-  static readonly PENDING_TTL_MS = 5 * 60 * 1000;
-
-  // -- Cleanup API --
-  onSessionDeleted(callback: (sessionId: string) => void): void;
-  dispatchSessionDeleted(sessionId: string): void;
-
-  // -- Signaling API --
-  /** Mark sessionId as having pending file-tool state. */
-  markPending(sessionId: string): void;
-  /**
-   * Atomically consume pending state for sessionId.
-   * Returns true if this call consumed the pending state,
-   * false if it was already consumed or never pending.
-   * Only one caller will get true per markPending call.
-   */
-  consumePending(sessionId: string): boolean;
-  /** True if sessionId had pending state that was consumed (checked with TTL). */
-  hasPendingSession(sessionId: string): boolean;
-  /** Remove all state for sessionId (called on session.deleted). */
-  clearSession(sessionId: string): void;
-}
-```
-
-**Pending-session signaling channel.** The module-scoped Sets from `post-file-tool-nudge` move here. TTL uses timestamp + lazy expiry on read (no `setTimeout`, no timer lifecycle bugs):
-
-```typescript
-hasPendingSession(sessionId: string): boolean {
-  const ts = this.#pendingTimestamps.get(sessionId);
-  if (ts && Date.now() - ts > SessionLifecycle.PENDING_TTL_MS) {
-    this.#pendingTimestamps.delete(sessionId);
-    this.#pendingSessionIds.delete(sessionId);
-    return false;
-  }
-  return this.#everPendingSessionIds.has(sessionId)
-    && !this.#pendingSessionIds.has(sessionId);
-}
-
-dispatchSessionDeleted(sessionId: string): void {
-  for (const callback of this.#cleanupCallbacks) {
-    try {
-      callback(sessionId);
-    } catch (error) {
-      log.error(`cleanup callback failed for session ${sessionId}`, error);
-    }
-  }
-}
-```
-
-Hooks that use it:
-- `task-session-manager`: registers 6 cleanup ops as one callback
-- `foreground-fallback`: registers 7 cleanup ops as one callback
-- `post-file-tool-nudge`: registers cleanup of pending state; imports `markPending`, `consumePending` from coordinator
-- `phase-reminder`: imports `hasPendingSession` from coordinator instead of `../post-file-tool-nudge` — and nothing else. No need for `markPending` or `consumePending`.
-
-The coordinator is instantiated in `src/index.ts` before hook factories that need it, passed as a parameter.
-
-### 3. `src/hooks/hook-registry.ts` — HookRegistry
-
-Simple ordered handler registry:
-
-```typescript
-class HookRegistry {
-  #handlers = new Map<string, Array<(input: unknown, output: unknown) => Promise<void>>>();
-  #firedHookPoints = new Set<string>();
-
-  register(
-    hookPoint: string,
-    handler: (input: unknown, output: unknown) => Promise<void>,
-  ): void {
-    if (this.#firedHookPoints.has(hookPoint)) {
-      log.warn(`hook "${hookPoint}" already dispatched; late registration may miss events`);
-    }
-    // ...
-  }
-
-  dispatch(hookPoint: string, input: unknown, output: unknown): Promise<void>;
-  getHandlers(hookPoint: string): ReadonlyArray<...>;
-}
-```
-
-Loose typing (`(input: unknown, output: unknown)`) is intentional — typed wrappers per hook point would add ceremony without proportional value for a codebase where call sites are already close to the cast. If typing becomes painful, add typed wrapper methods.
-
-- Registration order = dispatch order.
-- All async hook points dispatch through the registry.
-- `chat.headers` at `src/index.ts:980` stays manual (sync property, not async). A comment at the dispatch site explains why.
-- Non-hook event handling (multiplexer, companion, interview, preset, depthTracker) stays manual.
-
-Touch-point reduction for adding a new hook:
-- Export from `src/hooks/index.ts`: still required
-- Import in `src/index.ts`: still required
-- Variable declaration: **removed**
-- Factory call: still required
-- Registration: **added** (`registry.register(hookPoint, handler)`)
-- Dispatch-site wiring: **removed**
-
-Net: ~3 touch points eliminated. Dispatch code shrinks from ~121 lines to a few `registry.dispatch()` calls.
-
-## Changes by file
-
-### Phase 0: Baseline
-
-Run `bun test` and record the output. This ensures regressions in Phase 3 can be bisected.
-
-### Phase 1: `src/utils/extract-session-id.ts` (new)
-
-- Create file with `extractSessionId` function.
-- Tests in `src/utils/extract-session-id.test.ts`.
-
-### Phase 2: `src/hooks/session-lifecycle.ts` (new)
-
-- Create file with `SessionLifecycle` class.
-- Tests in `src/hooks/session-lifecycle.test.ts`.
-
-### Phase 2: Update hooks
-
-- `src/hooks/post-file-tool-nudge/index.ts`: delete module-scoped Sets, delete `hasPendingSession` export, delete reversed-priority `sessionID ?? info?.id`, use `extractSessionId`, add `coordinator: SessionLifecycle` param to factory, register cleanup callback.
-- `src/hooks/phase-reminder/index.ts`: import `hasPendingSession` from `session-lifecycle` instead of `../post-file-tool-nudge`.
-- `src/hooks/task-session-manager/index.ts`: replace 4 `info?.id ?? sessionID` with `extractSessionId`. Replace inline cleanup with coordinator callback registration.
-- `src/hooks/foreground-fallback/index.ts`: replace `info?.id ?? sessionID` with `extractSessionId`. Replace inline cleanup with coordinator callback registration.
-
-### Phase 3: `src/hooks/hook-registry.ts` (new)
-
-- Create file with `HookRegistry` class.
-- Tests in `src/hooks/hook-registry.test.ts`.
-
-### Phase 3: Update `src/index.ts`
-
-- Delete variable declarations for hooks (lines 138-153).
-- Delete imports for hook types/types that become unused.
-- Instantiate `SessionLifecycle` before hook factories.
-- Pass `SessionLifecycle` to hooks that need it.
-- Instantiate `HookRegistry` after all factories.
-- Register each hook's handlers with the registry.
-- Replace manual dispatch in `event` handler, `tool.execute.before`, `command.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.chat.messages.transform` with `registry.dispatch()`.
-- Keep `chat.headers` manual (sync). Add comment explaining why so maintainers don't try to "fix" it.
-- Keep non-hook dispatch manual (multiplexer, companion, interview, preset, depthTracker).
-- Deduplicate the two `session.deleted` blocks using `extractSessionId`.
-
-### Tests to update
-
-- `src/hooks/post-file-tool-nudge/index.test.ts`: pass coordinator to factory.
-- `src/hooks/phase-reminder/index.test.ts`: update import path for `hasPendingSession`.
-- `src/hooks/task-session-manager/index.test.ts`: verify cleanup through coordinator.
-- `src/index.ts` integration tests: nothing should break — the Plugin function returns the same shape.
-
-## Acceptance criteria
-
-- [ ] `extractSessionId` replaces all 8 instances, priority is always `info?.id ?? sessionID`
-- [ ] `extractSessionId` does not append redundant `?? undefined`
-- [ ] `post-file-tool-nudge` uses the same priority as all other locations
-- [ ] `SessionLifecycle.dispatchSessionDeleted` runs all registered cleanup callbacks
-- [ ] Cleanup callback errors are caught and logged, remaining callbacks still run
-- [ ] `consumePending` is atomic — only one caller gets `true` per `markPending` call
-- [ ] `SessionLifecycle.hasPendingSession` respects TTL and doesn't return stale entries
-- [ ] `SessionLifecycle.clearSession` cleans up pending state and timers
-- [ ] `HookRegistry` warns when a handler is registered after its hook point has dispatched
-- [ ] `HookRegistry.dispatch` runs handlers in registration order
-- [ ] Adding a new hook requires registering with the registry — no manual dispatch-site wiring
-- [ ] `chat.headers` still works (manual sync dispatch unchanged, with explanatory comment)
-- [ ] All 1367 existing tests pass
-- [ ] `bun run check:ci` passes
-- [ ] `bun run typecheck` passes
-
-## Out of scope
-
-- Changing the hook factory pattern (factories still return handler maps)
-- Adding new hooks to the codebase
-- Changing handler signatures (e.g., `experimental.chat.messages.transform`)
-- Non-hook event handling (multiplexer, companion, interview, preset, depthTracker)
-- Sync hook points like `chat.headers`
-- Configurable TTL (keep as static constant, YAGNI)
-- Dynamic hook registration after dispatch (runtime guard logs a warning, not an error)
-- Typed dispatch wrappers per hook point (YAGNI; add if casts become painful)

+ 318 - 2
src/multiplexer/herdr/index.test.ts

@@ -346,6 +346,41 @@ describe('HerdrMultiplexer', () => {
     expect(result).toEqual({ success: false });
   });
 
+  test('closes orphaned pane when pane run fails (non-zero exit)', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p2')}\n`);
+      }
+      // run returns non-zero; everything else (close, send-keys) succeeds
+      if (command.includes('run')) {
+        return createSpawnResult(1, '', 'run failed');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+
+    const closeCommands = commands().filter(
+      (c) => c[1] === 'pane' && c[2] === 'close',
+    );
+    expect(closeCommands).toEqual([
+      ['/usr/bin/herdr', 'pane', 'close', 'w1:p2'],
+    ]);
+  });
+
   test('main-horizontal layout opens panes down', async () => {
     const { HerdrMultiplexer } = await importFreshHerdr();
     const herdr = new HerdrMultiplexer('main-horizontal', 60);
@@ -406,6 +441,70 @@ describe('HerdrMultiplexer', () => {
     expect(splitCommand?.[directionArgIndex + 1]).toBe('right');
   });
 
+  test('tiled layout always splits parent right (unaffected)', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('tiled', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[0]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+  });
+
+  test('main-horizontal layout always splits parent down (unaffected)', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-horizontal', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[0]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'down',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'down',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+  });
+
   test('isInsideSession returns true when HERDR_ENV is set', async () => {
     const { HerdrMultiplexer } = await importFreshHerdr();
     const herdr = new HerdrMultiplexer('main-vertical', 60);
@@ -432,13 +531,230 @@ describe('HerdrMultiplexer', () => {
     expect(herdr.isInsideSession()).toBe(false);
   });
 
-  test('applyLayout is a no-op', async () => {
+  test('stores layout from constructor', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+    // @ts-expect-error - accessing private for test
+    expect(herdr.layout).toBe('main-vertical');
+    // @ts-expect-error
+    expect(herdr.agentAreaPaneId).toBeNull();
+  });
+
+  test('main-vertical: 2nd spawn splits agent area down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'Agent 1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'Agent 2', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[0]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p2',
+      '--direction',
+      'down',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+  });
+
+  test('main-vertical: 3rd spawn splits same agent area down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s3', 'A3', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[2]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p2',
+      '--direction',
+      'down',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+  });
+
+  test('main-vertical: fallback to parent when agent area closed', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const r1 = await herdr.spawnPane(
+      's1',
+      'A1',
+      'http://localhost:4096',
+      '/repo',
+    );
+    // Simulate agent area pane being closed externally
+    await herdr.closePane(r1.paneId as string);
+
+    // Next spawn should split from parent (w1:p1) → right, not from stale w1:p2
+    await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    // 1st: parent → right (w1:p1)
+    expect(splitCommands[0]).toContain('w1:p1');
+    // 2nd (after close): parent → right again (w1:p1), not w1:p2
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+  });
+
+  test('main-vertical: implicit fallback when agent area split fails', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    // First spawn creates agent area (w1:p2)
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+
+    // Mock so split targeting w1:p2 (agent area, direction=down) fails
+    // but split targeting w1:p1 (parent, direction=right) succeeds
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (
+        command.includes('split') &&
+        command.includes('w1:p2') &&
+        command.includes('down')
+      ) {
+        return createSpawnResult(1, '', 'agent area pane gone');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p3')}\n`);
+      }
+      return createSpawnResult();
+    });
+
+    // Second spawn: agent area split fails → falls back to parent split
+    const result = await herdr.spawnPane(
+      's2',
+      'A2',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'w1:p3' });
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+
+    // 1st: parent split → right (first spawn)
+    expect(splitCommands[0]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+
+    // 2nd: agent area split → down (second spawn) — fails
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p2',
+      '--direction',
+      'down',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+
+    // 3rd: parent split → right (fallback from failed agent area split)
+    expect(splitCommands[2]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+
+    // agentAreaPaneId was updated to the new pane from the parent split
+    // @ts-expect-error - accessing private for test
+    expect(herdr.agentAreaPaneId).toBe('w1:p3');
+  });
+
+  test('closePane clears agentAreaPaneId when agent area closed', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const r1 = await herdr.spawnPane(
+      's1',
+      'A1',
+      'http://localhost:4096',
+      '/repo',
+    );
+    await herdr.closePane(r1.paneId as string);
+
+    // @ts-expect-error - accessing private for test
+    expect(herdr.agentAreaPaneId).toBeNull();
+  });
+
+  test('closePane does NOT clear agentAreaPaneId for non-agent pane', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    // Close a different pane (simulated)
+    await herdr.closePane('w1:p99');
+
+    // @ts-expect-error
+    expect(herdr.agentAreaPaneId).not.toBeNull();
+  });
+
+  test('applyLayout clears agentAreaPaneId', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    await herdr.applyLayout('tiled', 50);
+
+    // @ts-expect-error
+    expect(herdr.agentAreaPaneId).toBeNull();
+  });
+
+  test('applyLayout issues no CLI commands', async () => {
     const { HerdrMultiplexer } = await importFreshHerdr();
     const herdr = new HerdrMultiplexer('main-vertical', 60);
 
     await herdr.applyLayout('tiled', 50);
 
-    // Only the binary check command should have been issued
+    // No CLI commands should be issued
     expect(commands()).toHaveLength(0);
   });
 });

+ 98 - 42
src/multiplexer/herdr/index.ts

@@ -39,12 +39,15 @@ export class HerdrMultiplexer implements Multiplexer {
   private binaryPath: string | null = null;
   private hasChecked = false;
   private readonly parentPaneId = process.env.HERDR_PANE_ID;
-  private readonly paneDirection: HerdrPaneDirection;
+  private layout: MultiplexerLayout;
+  private paneDirection: HerdrPaneDirection;
+  private agentAreaPaneId: string | null = null;
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     // Herdr does not support exact main pane sizing like tmux.
     // Layout config is mapped to pane split direction.
     void mainPaneSize;
+    this.layout = layout;
     this.paneDirection = getPaneDirection(layout);
   }
 
@@ -79,43 +82,37 @@ export class HerdrMultiplexer implements Multiplexer {
       // corrupt --cwd (issue #568).
       const attachDir = normalizePathForShell(directory);
 
-      // 1. Split the parent pane to create a new one
-      const splitArgs = [
-        herdr,
-        'pane',
-        'split',
-        ...this.targetPaneArg(),
-        '--direction',
-        this.paneDirection,
-        '--cwd',
-        attachDir,
-        '--no-focus',
-      ];
-
-      log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+      let paneId: string | null = null;
+      let lastRawOutput = '';
 
-      const splitProc = crossSpawn(splitArgs, {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const splitExitCode = await splitProc.exited;
-      const splitStdout = await splitProc.stdout();
-      const splitStderr = await splitProc.stderr();
+      if (this.layout === 'main-vertical' && this.agentAreaPaneId) {
+        const result = await this.runSplit(
+          [this.agentAreaPaneId],
+          'down',
+          attachDir,
+        );
+        paneId = result.paneId;
+        if (!paneId) {
+          log('[herdr] agent area split failed, falling back to parent', {
+            agentAreaPaneId: this.agentAreaPaneId,
+          });
+          this.agentAreaPaneId = null;
+        }
+      }
 
-      if (splitExitCode !== 0) {
-        log('[herdr] spawnPane: split failed', {
-          exitCode: splitExitCode,
-          stderr: splitStderr.trim(),
-        });
-        return { success: false };
+      if (!this.agentAreaPaneId) {
+        const result = await this.runSplit(
+          this.targetPaneArg(),
+          this.paneDirection,
+          attachDir,
+        );
+        paneId = result.paneId;
+        lastRawOutput = result.rawOutput;
       }
 
-      // Parse JSON response to extract pane_id
-      const paneId = parsePaneId(splitStdout);
       if (!paneId) {
         log('[herdr] spawnPane: could not parse pane_id from output', {
-          stdout: splitStdout.trim(),
+          stdout: lastRawOutput,
         });
         return { success: false };
       }
@@ -133,11 +130,6 @@ export class HerdrMultiplexer implements Multiplexer {
         attachDir,
       );
 
-      log('[herdr] spawnPane: running attach command', {
-        paneId,
-        command: opencodeCmd,
-      });
-
       const runProc = crossSpawn([herdr, 'pane', 'run', paneId, opencodeCmd], {
         stdout: 'pipe',
         stderr: 'pipe',
@@ -150,9 +142,25 @@ export class HerdrMultiplexer implements Multiplexer {
           exitCode: runExitCode,
           stderr: runStderr.trim(),
         });
+        // ponytail: split succeeded but attach failed; close the orphaned pane
+        // so it does not linger in the agent column. Session manager gets no
+        // paneId on failure, so we must clean it up here.
+        try {
+          await this.closePane(paneId);
+        } catch (closeErr) {
+          log('[herdr] spawnPane: failed to close orphaned pane', {
+            paneId,
+            error: String(closeErr),
+          });
+        }
         return { success: false };
       }
 
+      // 4. Track agent area pane ID only after successful attach
+      if (this.layout === 'main-vertical' && !this.agentAreaPaneId) {
+        this.agentAreaPaneId = paneId;
+      }
+
       log('[herdr] spawnPane: SUCCESS', { paneId });
       return { success: true, paneId };
     } catch (err) {
@@ -163,21 +171,69 @@ export class HerdrMultiplexer implements Multiplexer {
 
   async closePane(paneId: string): Promise<boolean> {
     const herdr = await this.getBinary();
-    return gracefulClosePane(herdr, paneId, {
+    const closed = await gracefulClosePane(herdr, paneId, {
       ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
       close: ['pane', 'close', paneId],
       acceptExitCode1: true,
       emptyPaneReturnsTrue: true,
     });
+    if (closed && paneId === this.agentAreaPaneId) {
+      this.agentAreaPaneId = null;
+    }
+    return closed;
   }
 
   async applyLayout(
-    _layout: MultiplexerLayout,
+    layout: MultiplexerLayout,
     _mainPaneSize: number,
   ): Promise<void> {
-    // No-op for herdr. Herdr does not support tmux-like exact main pane
-    // sizing/rebalancing; layout is applied to future pane creation by
-    // mapping configured layouts to pane split directions.
+    // ponytail: herdr has no rebalancing API; clear agent area so a layout
+    // switch starts fresh from the parent pane.
+    this.agentAreaPaneId = null;
+    this.layout = layout;
+    this.paneDirection = getPaneDirection(layout);
+  }
+
+  private async runSplit(
+    target: string[],
+    direction: HerdrPaneDirection,
+    directory: string,
+  ): Promise<{ paneId: string | null; rawOutput: string }> {
+    const herdr = await this.getBinary();
+    if (!herdr) return { paneId: null, rawOutput: '' };
+
+    const splitArgs = [
+      herdr,
+      'pane',
+      'split',
+      ...target,
+      '--direction',
+      direction,
+      '--cwd',
+      directory,
+      '--no-focus',
+    ];
+
+    log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+
+    const splitProc = crossSpawn(splitArgs, {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const splitExitCode = await splitProc.exited;
+    const splitStdout = await splitProc.stdout();
+    const splitStderr = await splitProc.stderr();
+
+    if (splitExitCode !== 0) {
+      log('[herdr] spawnPane: split failed', {
+        exitCode: splitExitCode,
+        stderr: splitStderr.trim(),
+      });
+      return { paneId: null, rawOutput: splitStdout.trim() };
+    }
+
+    return { paneId: parsePaneId(splitStdout), rawOutput: splitStdout.trim() };
   }
 
   private targetPaneArg(): string[] {