Browse Source

chore: ignore docs/superpowers and stop tracking it

Planning/spec docs under docs/superpowers are local working artifacts, not
release content. Add docs/superpowers to .gitignore and remove the tracked
files from the branch (kept on disk via the ignore).
Michael Henke 3 weeks ago
parent
commit
2f5c36d76b

+ 1 - 0
.gitignore

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

+ 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)

+ 0 - 143
docs/superpowers/specs/2026-07-07-herdr-main-vertical-design.md

@@ -1,143 +0,0 @@
-# Herdr `main-vertical` Layout Fix — Design Spec
-
-**Date:** 2026-07-07
-**Issue:** #668 — Herdr: implement proper `main-vertical` layout instead of repeatedly splitting right
-**Branch:** `feat/herdr-main-vertical-668`
-
-## Problem
-
-Herdr's `main-vertical` multiplexer layout currently maps to `--direction right` for **every** child pane. This means each newly spawned subagent creates another rightward split from the parent pane, fragmenting the workspace into an increasingly narrow horizontal strip layout. The parent OpenCode pane loses visual priority and agents become hard to read.
-
-Expected behavior (tmux `main-vertical` style):
-- Parent OpenCode pane stays large on the left (~60% width, full height)
-- First subagent opens in a right-side pane (full height of right column)
-- Subsequent subagents stack **vertically** within the right-side column
-- Parent pane remains dominant as agents are added
-
-## Approach
-
-Track a single `agentAreaPaneId` — the first child pane created in the right column. Route subsequent child spawns to split **down** from that agent area pane, instead of repeatedly splitting right from the parent.
-
-### State Changes (`HerdrMultiplexer`)
-
-```typescript
-private layout: MultiplexerLayout;                  // mutable — applyLayout writes it
-private paneDirection: HerdrPaneDirection;          // mutable — applyLayout writes it
-private agentAreaPaneId: string | null = null;      // tracks first child in right column
-```
-
-- `layout` is stored (previously discarded after computing `paneDirection`)
-- `paneDirection` is retained for non-`main-*` layouts (`even-*`, `tiled`)
-- `agentAreaPaneId` is the new tracking field
-
-### `spawnPane` Logic
-
-```
-spawnPane(sessionId, description, serverUrl, directory):
-  1. Get herdr binary (existing)
-
-  2. Determine split target and direction:
-     let paneId = null
-
-     if layout === 'main-vertical' AND agentAreaPaneId is set:
-       paneId = runSplit(target=[agentAreaPaneId], direction='down')
-       if paneId is null:
-         log('[herdr] agent area split failed, falling back to parent', {...})
-         agentAreaPaneId = null
-
-     if agentAreaPaneId is null:  // first child OR fallback from failed split
-       paneId = runSplit(target=targetPaneArg(), direction=paneDirection)
-
-     if paneId is null:
-       log('[herdr] spawnPane: could not parse pane_id from output', {stdout})
-       return { success: false }
-
-    3. Rename pane, run opencode attach (existing)
-    4. If layout === 'main-vertical' AND agentAreaPaneId is null:
-        agentAreaPaneId = paneId
-```
-
-Key properties:
-- **First child**: agentAreaPaneId is null → splits parent → right, creating the agent area
-- **Subsequent children**: agentAreaPaneId is set → splits agent area → down, stacking vertically
-- **Stale reference** (agent area pane closed externally): split fails → log → clear → fall through to parent split (re-creates agent area)
-- **Implicit `!agentAreaPaneId` gate** replaces the `wasFirstChild` flag — the same `null` check handles both first-child and fallback
-- **Gated on `main-vertical` only** — other layouts unchanged
-
-### `closePane` Change
-
-`agentAreaPaneId` is cleared **inside** the success branch, only after the close command confirms success (exit code 0 or 1).
-
-```typescript
-async closePane(paneId: string): Promise<boolean> {
-  if (!paneId || paneId === 'unknown') return true;
-
-  const herdr = await this.getBinary();
-  // ... send Ctrl+C, wait, run close ...
-
-  const exitCode = await proc.exited;
-  const stderr = await proc.stderr();
-
-  // Inside the success branch only
-  if (exitCode === 0 || exitCode === 1) {
-    if (paneId === this.agentAreaPaneId) {
-      this.agentAreaPaneId = null;  // next spawn re-creates from parent
-    }
-    return true;
-  }
-
-  return false;
-}
-```
-
-### `applyLayout` Change
-
-```typescript
-async applyLayout(layout: MultiplexerLayout, _mainPaneSize: number): Promise<void> {
-  // 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);
-}
-```
-
-## Scope
-
-**In scope:**
-- `main-vertical` layout for Herdr multiplexer
-- Tracking agent area pane, smart split targeting, fallback on stale reference
-- `closePane` and `applyLayout` cleanup
-- Tests for main-vertical behavior
-- Docs update for actual Herdr main-vertical behavior
-
-**Out of scope (YAGNI):**
-- `main-horizontal` layout (same pattern, swapped axes — not requested)
-- Full pane-list querying of Herdr state
-- Layout rebalancing (Herdr has no API for it)
-- Concurrent spawn locking (current architecture is sequential)
-
-## Testing
-
-Tests to add in `src/multiplexer/herdr/index.test.ts`:
-
-1. `main-vertical` 1st spawn → split parent → right
-2. `main-vertical` 2nd spawn → split agent area → down
-3. `main-vertical` 3rd spawn → split agent area → down (same target)
-4. Agent area pane closed → next spawn falls back to parent → right
-5. `tiled` layout → always splits parent → right (unaffected)
-6. `main-horizontal` layout → always splits parent → down (unaffected)
-7. Closing a non-agent-area pane does NOT clear `agentAreaPaneId`
-
-## Documentation
-
-Update `docs/multiplexer-integration.md`:
-- Clarify Herdr `main-vertical` now stacks agents vertically in a right column
-- Parent OpenCode pane stays dominant
-- Note any limitations (no exact main-pane-width sizing like tmux)
-
-## Verification
-
-- `bun test src/multiplexer/herdr/index.test.ts` — all tests pass
-- `bun run typecheck` — no type errors
-- `bun run check:ci` — lint/format clean