瀏覽代碼

Merge branch 'master' into feat/hook-registry-session-lifecycle

Alvin 1 月之前
父節點
當前提交
e7ff83dabf

+ 23 - 22
.slim/codemap.json

@@ -1,8 +1,8 @@
 {
   "metadata": {
     "version": "1.0.0",
-    "last_run": "2026-07-06T18:13:36.752Z",
-    "root": "/home/mhenke/Projects/oh-my-opencode-slim/.worktrees/hook-registry-session-lifecycle",
+    "last_run": "2026-07-06T18:55:02.720Z",
+    "root": "/home/mhenke/Projects/oh-my-opencode-slim",
     "include_patterns": [
       "src/**/*.ts",
       "src/**/*.d.ts",
@@ -94,20 +94,19 @@
     "src/hooks/delegate-task-retry/hook.ts": "310c87963909ab3f40da5a61a50f5df0",
     "src/hooks/delegate-task-retry/patterns.ts": "5e4919da29af630e4e2ec37df0b58025",
     "src/hooks/filter-available-skills/index.ts": "4278d46c9018b0a86f0e63349f007f4d",
-    "src/hooks/foreground-fallback/index.ts": "a4d2b7c1a73f3d19d928c0c5697dcb8a",
+    "src/hooks/foreground-fallback/index.ts": "2c53fd4684f6c004640d7d20517cc4aa",
     "src/hooks/image-hook.ts": "11ff86ce46a479429938e27ca05499a3",
-    "src/hooks/index.ts": "7a124e8319760e9f38879764c4a6a0d2",
+    "src/hooks/index.ts": "5036bb33b2c881554f57bd6b36459b8c",
     "src/hooks/json-error-recovery/hook.ts": "6b86f68cdf202725ed856c07de622b62",
     "src/hooks/loop-command/index.ts": "51dc18e6233083c854eb5b3b241b3946",
-    "src/hooks/phase-reminder/index.ts": "7935ac637be200a033cca0823c519e2d",
-    "src/hooks/post-file-tool-nudge/index.ts": "4ad6fabab91b0a0fc79493850b2361d8",
+    "src/hooks/phase-reminder/index.ts": "445cf012d3e13c242e64e37d70fae625",
+    "src/hooks/post-file-tool-nudge/index.ts": "7c7d1c25e4b6e445dfbf506b483cde82",
     "src/hooks/reflect/index.ts": "01118001ad9d4e560881b4ef06b78d9f",
-    "src/hooks/session-lifecycle.ts": "f9fb850bf3a859457e0089a7ec22906b",
-    "src/hooks/task-session-manager/index.ts": "6e364801e158ba246fce8dc2a2bc4216",
+    "src/hooks/task-session-manager/index.ts": "90b63e3321468082004b81da653fdf4a",
     "src/hooks/task-session-manager/pending-call-tracker.ts": "4650c2f9bc9ea4b1e13b513989e5fc9e",
     "src/hooks/task-session-manager/task-context-tracker.ts": "e6dc74e72aedd014643134afb2c123a6",
     "src/hooks/types.ts": "0a80ebc8b12032bd9cc810e5fab6b6a1",
-    "src/index.ts": "bc7e16aaf4b191f88fedcc9753ee1f4c",
+    "src/index.ts": "b1f9feda05098802712f89990c1c209b",
     "src/interview/dashboard-manager.ts": "40475436feb2773a825db89827ab28f5",
     "src/interview/dashboard.ts": "cfff71b7ca3a38b9e58e3ed96262ccea",
     "src/interview/document.ts": "29e6a6d42c0d0b502dd25a022c4e1de4",
@@ -130,7 +129,7 @@
     "src/multiplexer/factory.ts": "f46b52b6269d4a1e97a4822ab354a7c0",
     "src/multiplexer/herdr/index.ts": "150a0b0f255ff05ab8c129cf53b4b17c",
     "src/multiplexer/index.ts": "7c3ac610930726ff8d1d12aee025752b",
-    "src/multiplexer/session-manager.ts": "6040949e83c4bc464e79967a41d8fa3a",
+    "src/multiplexer/session-manager.ts": "5622de7069f444d5162c09b1e347aa34",
     "src/multiplexer/shared.ts": "ea19d777dd58300a8a73a26887c7178d",
     "src/multiplexer/tmux/index.ts": "6a917050c75fa34fea61035b51bbc46c",
     "src/multiplexer/types.ts": "8aa8ffcdbaf33dbacd0725d98f0a3b68",
@@ -146,7 +145,7 @@
     "src/tools/ast-grep/tools.ts": "a0d7b252fb2240c8e064b495c19e1f26",
     "src/tools/ast-grep/types.ts": "34ad28b5b1e9617b584f082dba9a427c",
     "src/tools/ast-grep/utils.ts": "1dd3b2133c4b8c847a26eea0423bc0b2",
-    "src/tools/cancel-task.ts": "f370b93bb0623774d3846e5a4dd30d2c",
+    "src/tools/cancel-task.ts": "c55e4173b619dcf199d72c6ecd6f4d09",
     "src/tools/council.ts": "5178d86b649777c753ffb4cc9a31dcfc",
     "src/tools/index.ts": "b562a39a524d55c1b0b33041b62437e8",
     "src/tools/preset-manager.ts": "5dc472acedb4fb5e17a584bbb7e190e0",
@@ -162,12 +161,14 @@
     "src/tui-state.ts": "dd8cbf2d515085edc548cdbecfd1ba09",
     "src/tui.ts": "12d1e248f4eba8d6f54b164a742cab9c",
     "src/utils/agent-variant.ts": "6e112fb56a0eef55c8c1dbff3e9d7c8e",
-    "src/utils/background-job-board.ts": "ca9562ef4abd51af8e0feac98337f9af",
+    "src/utils/background-job-board.ts": "fcdd672d9f3a8730d3db39470e0da61d",
+    "src/utils/background-job-coordinator.ts": "e972bdc1bbab9f4f458a0828ddb0af69",
+    "src/utils/background-job-store.ts": "2114ed7af09bb37e207b0969385707e8",
     "src/utils/compat.ts": "efb1d9db45c0926079cb780e949fb5dd",
     "src/utils/councillor-models.ts": "5155941c035ecfd11a4b6248e6c497aa",
     "src/utils/env.ts": "c4d56b5c308c1047c26d494be45cb86b",
     "src/utils/guards.ts": "83af4d036dd573e9008f0c1125e4918c",
-    "src/utils/index.ts": "9658d64a4ef4ec45b14b331feaa88f35",
+    "src/utils/index.ts": "42b1f364b48e75faa9cfeb44c6ad2660",
     "src/utils/internal-initiator.ts": "013b87f387555db563b0241645d638b1",
     "src/utils/logger.ts": "4e33d48e57ab1ea40cfe07a796d4ac44",
     "src/utils/polling.ts": "b1d9c52df1fae7391234d0f5476d53b5",
@@ -181,29 +182,29 @@
   "folder_hashes": {
     ".": "f4a663f6aac32f8ce0db004493bf6297",
     "scripts": "362e9fb6832c103e1434719916be283d",
-    "src": "ad66fe4ef0ac6bdd1385608b74c143dc",
+    "src": "a05b32fdbbecf0039f0a25353464ca1f",
     "src/agents": "14bf78ef0216501e70602f12d62e9839",
     "src/cli": "68e0366396ce0091fbfc8693500ef242",
     "src/companion": "489154aba4297da2a659a31bd91beb15",
     "src/config": "9cd27db0477bccd91e793a4b51aee90c",
     "src/council": "a25cae62a980ff28f7d1834236c46730",
-    "src/hooks": "5bcdcc99fadb6cd58a7c1a2d23ae8470",
+    "src/hooks": "89952c2777fb40f35be388f989e4b1b0",
     "src/hooks/apply-patch": "d20e3c103082283c3c126b7e936bc041",
     "src/hooks/auto-update-checker": "afe6d8213cc900b772b9c04ab6a31fd6",
     "src/hooks/deepwork": "4698a85b598d3158d038313680762c1b",
     "src/hooks/delegate-task-retry": "7bd4abeb2dbfc4e7aaed701de08b7509",
     "src/hooks/filter-available-skills": "2f0f2cbced0fbe91931733f645209019",
-    "src/hooks/foreground-fallback": "fd49097d81748eb7a4adb54fdf0cb7f4",
+    "src/hooks/foreground-fallback": "0f4879b5d2ae0a9fbceef13fd7357635",
     "src/hooks/json-error-recovery": "fbe725b787123f203b78dd8dfd47db67",
     "src/hooks/loop-command": "dd9465f50406f33c005b559dabfeec29",
-    "src/hooks/phase-reminder": "641a71276e0f2174a0ce7ed87582f17b",
-    "src/hooks/post-file-tool-nudge": "25abec49e30506a838c7b225e1e2d892",
+    "src/hooks/phase-reminder": "d941ded91b104a463725474697a8a9e9",
+    "src/hooks/post-file-tool-nudge": "7725bef64323410b08082fa5aa060946",
     "src/hooks/reflect": "8e5aa8b074b44978ab071ef0ff56201a",
-    "src/hooks/task-session-manager": "d60a6ee190aebcdcd74f8154dbf9b6b8",
+    "src/hooks/task-session-manager": "009cf44a57cdb6b881ed027e477b2c3c",
     "src/interview": "316bcd76b235d932872172a6891f1b68",
     "src/loop": "366bf2141b605deb1849f9783c993810",
     "src/mcp": "1db30ec46ae0b577ec22b74e2b4d19ea",
-    "src/multiplexer": "8728412bff7b7f2af534333384584be2",
+    "src/multiplexer": "a8ea1efc12f7d75a9af06b25da7d5664",
     "src/multiplexer/herdr": "e9ca0fd14736da1ea49ccca1a1736733",
     "src/multiplexer/tmux": "f98f01b8553bf556ec302744cf61bf45",
     "src/multiplexer/zellij": "d01383270dbd0f1fad6b67d85a69fe30",
@@ -211,9 +212,9 @@
     "src/skills/clonedeps": "d1d19753438fdb845f4efca93314a147",
     "src/skills/codemap": "1e82ef833612703b786daceb091f2422",
     "src/skills/simplify": "9c745d8113135e3103af5f1a49d67dfe",
-    "src/tools": "abebb2d2135e70003f58fd3241ecf82a",
+    "src/tools": "cd35887510b0bab8c89cba8c71a8456a",
     "src/tools/ast-grep": "7091c20c0d028c22effa2b5c1e64cc58",
     "src/tools/smartfetch": "b71f3f9464bb203a3ae3a4521eea01a6",
-    "src/utils": "9d89030117a87f6dc9dab9a667dbe269"
+    "src/utils": "cc5583330e527a04184e4a1136e721d4"
   }
 }

+ 9 - 0
docs/configuration.md

@@ -28,6 +28,15 @@ OH_MY_OPENCODE_SLIM_DISABLE=1 opencode
 
 If OmO-slim detects an invalid plugin config for the current project, the TUI sidebar shows a warning. Run `oh-my-opencode-slim doctor` from your project root for full diagnostics.
 
+The TUI sidebar uses the compact layout by default. Set `compactSidebar` to
+`false` in `oh-my-opencode-slim.jsonc` to use the expanded layout:
+
+```jsonc
+{
+  "compactSidebar": false
+}
+```
+
 ---
 
 ## Prompt Overriding

+ 43 - 4
docs/installation.md

@@ -331,17 +331,43 @@ See the [Multiplexer Integration Guide](multiplexer-integration.md) for more det
 
 ## Uninstallation
 
-1. **Remove the plugin from your OpenCode config**:
+### Required
 
-   Edit `~/.config/opencode/opencode.json` and remove `"oh-my-opencode-slim"` from the `plugin` array.
+1. Remove the plugin from your OpenCode config:
 
-2. **Remove configuration files (optional)**:
+   Edit `~/.config/opencode/opencode.json` and remove `"oh-my-opencode-slim"` from the `plugin` array. If the installer enabled LSP (it only does so when no explicit `lsp` setting exists), set `"lsp": false` or remove the `"lsp"` key.
+
+2. Remove the TUI badge:
+
+   Edit `~/.config/opencode/tui.json` and remove `"oh-my-opencode-slim"` from the `plugin` array.
+
+### Optional Cleanup
+
+3. Re-enable default agents:
+
+   In `~/.config/opencode/opencode.json`, remove the `disable: true` entries the installer added under `agent.explore` and `agent.general`.
+
+4. Remove the environment variable:
+
+   The installer may have added an export to your shell startup file. Remove the `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` line from your shell config:
+   - `~/.zshrc` (Zsh)
+   - `~/.bashrc` (Bash)
+   - `~/.config/fish/conf.d/opencode-background-subagents.fish` (Fish) — also remove `set -gx OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS true`
+
+   Restart your terminal or `source` the file.
+
+5. Clear the plugin cache:
+   ```bash
+   rm -rf ~/.cache/opencode/packages/oh-my-opencode-slim@*
+   ```
+
+6. Remove configuration files:
    ```bash
    rm -f ~/.config/opencode/oh-my-opencode-slim.json
    rm -f ~/.config/opencode/oh-my-opencode-slim.json.bak
    ```
 
-3. **Remove skills (optional)**:
+7. Remove skills installed by the installer:
    ```bash
    rm -rf ~/.config/opencode/skills/simplify
    rm -rf ~/.config/opencode/skills/codemap
@@ -351,3 +377,16 @@ See the [Multiplexer Integration Guide](multiplexer-integration.md) for more det
    rm -rf ~/.config/opencode/skills/worktrees
    rm -rf ~/.config/opencode/skills/oh-my-opencode-slim
    ```
+
+   > **Note:** The installer manages these specific skills. If you added others manually, they won't be affected.
+
+8. Remove the desktop companion binary (if installed):
+
+   The companion is optional and not installed by default. If you installed it:
+   ```bash
+   rm -rf ~/.local/share/opencode/storage/oh-my-opencode-slim
+   ```
+
+### Verify
+
+Run `opencode auth status` and confirm oh-my-opencode-slim agents no longer appear.

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

@@ -0,0 +1,735 @@
+# 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
+```

+ 1 - 1
oh-my-opencode-slim.schema.json

@@ -9,7 +9,7 @@
       "type": "boolean"
     },
     "compactSidebar": {
-      "description": "Use the compact TUI sidebar layout when enabled.",
+      "description": "Use the compact TUI sidebar layout. Defaults to true; set false to use the expanded layout.",
       "type": "boolean"
     },
     "autoUpdate": {

+ 3 - 1
src/config/schema.ts

@@ -296,7 +296,9 @@ export const PluginConfigSchema = z
     compactSidebar: z
       .boolean()
       .optional()
-      .describe('Use the compact TUI sidebar layout when enabled.'),
+      .describe(
+        'Use the compact TUI sidebar layout. Defaults to true; set false to use the expanded layout.',
+      ),
     autoUpdate: z
       .boolean()
       .optional()

+ 2 - 1
src/hooks/task-session-manager/index.ts

@@ -2,6 +2,7 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
   type BackgroundJobRecord,
+  type BackgroundJobStore,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
   parseTaskLaunchOutput,
@@ -83,7 +84,7 @@ export function createTaskSessionManagerHook(
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
-    backgroundJobBoard?: BackgroundJobBoard;
+    backgroundJobBoard?: BackgroundJobStore;
     shouldManageSession: (sessionID: string) => boolean;
     /** Optional guard: when provided, idle events for a session that is
      *  currently undergoing a foreground-fallback abort/re-prompt cycle

+ 11 - 5
src/index.ts

@@ -54,6 +54,7 @@ import {
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
+  BackgroundJobCoordinator,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -261,15 +262,20 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
     });
 
+    // Initialize coordinator as the sole writer to the board
+    const backgroundJobCoordinator = new BackgroundJobCoordinator(
+      backgroundJobBoard,
+    );
+
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
     multiplexerSessionManager = new MultiplexerSessionManager(
       ctx,
       multiplexerConfig,
-      backgroundJobBoard,
+      backgroundJobCoordinator,
     );
-    backgroundJobBoard.addTerminalStateListener((taskID) => {
-      void multiplexerSessionManager.retryDeferredIdleClose(taskID);
+    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
     });
 
     sessionLifecycle = new SessionLifecycle(log);
@@ -303,7 +309,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
       isFallbackInProgress: (sessionID) =>
@@ -378,7 +384,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     );
     cancelTaskTools = createCancelTaskTool({
       client: ctx.client,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });

+ 41 - 29
src/multiplexer/session-manager.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from '../utils/background-job-board';
+import { BackgroundJobCoordinator } from '../utils/background-job-coordinator';
 import {
   MultiplexerSessionManager,
   resetMultiplexerSessionManagerState,
@@ -413,6 +414,7 @@ describe('MultiplexerSessionManager', () => {
     test('timed out running jobs still close after safe recovery and completion', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'timedout-child',
         parentSessionID: 'parent-1',
@@ -431,10 +433,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -483,6 +485,7 @@ describe('MultiplexerSessionManager', () => {
         mockMultiplexer.closePane.mockClear();
         const ctx = createMockContext();
         const board = new BackgroundJobBoard();
+        const coordinator = new BackgroundJobCoordinator(board);
         const sessionId = `deferred-${state}`;
         board.registerLaunch({
           taskID: sessionId,
@@ -496,10 +499,10 @@ describe('MultiplexerSessionManager', () => {
         const manager = new MultiplexerSessionManager(
           ctx,
           defaultMultiplexerConfig,
-          board,
+          coordinator,
         );
-        board.setTerminalStateListener((taskID) => {
-          void manager.retryDeferredIdleClose(taskID);
+        coordinator.addTerminalStateListener((sessionId) => {
+          void manager.closeSessionFromCoordinator(sessionId);
         });
 
         await manager.onSessionCreated({
@@ -522,6 +525,7 @@ describe('MultiplexerSessionManager', () => {
     test('deferred idle close retries on markCancelled', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'deferred-cancel',
         parentSessionID: 'parent-1',
@@ -534,10 +538,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -562,6 +566,7 @@ describe('MultiplexerSessionManager', () => {
     test('terminal status without deferred idle close does not close pane', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'terminal-without-defer',
         parentSessionID: 'parent-1',
@@ -574,10 +579,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -598,6 +603,7 @@ describe('MultiplexerSessionManager', () => {
     test('deleted clears deferred idle close and later terminal update is no-op', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'deleted-deferred',
         parentSessionID: 'parent-1',
@@ -610,10 +616,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -640,6 +646,7 @@ describe('MultiplexerSessionManager', () => {
     test('retry while still running keeps deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'still-running-deferred',
         parentSessionID: 'parent-1',
@@ -652,10 +659,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -672,7 +679,8 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
-      await manager.retryDeferredIdleClose('still-running-deferred');
+      // The coordinator's terminal state listener will handle the close
+      // when the job completes, so we don't need to call retryDeferredIdleClose directly
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
 
       board.updateStatus({
@@ -688,6 +696,7 @@ describe('MultiplexerSessionManager', () => {
     test('disabled manager does not retry deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'disabled-retry-deferred',
         parentSessionID: 'parent-1',
@@ -700,7 +709,7 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
 
       await manager.onSessionCreated({
@@ -718,19 +727,21 @@ describe('MultiplexerSessionManager', () => {
       });
 
       mockMultiplexer.isInsideSession.mockReturnValue(false);
-      const disabledManager = new MultiplexerSessionManager(
+      const _disabledManager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      await disabledManager.retryDeferredIdleClose('disabled-retry-deferred');
 
+      // The coordinator's terminal state listener will handle the close
+      // when the job completes, but the disabled manager should not close
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
     test('explicit non-idle status event clears stale deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'retry-event-deferred',
         parentSessionID: 'parent-1',
@@ -743,10 +754,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -793,6 +804,7 @@ describe('MultiplexerSessionManager', () => {
     test('explicit non-idle poll clears stale deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'resumed-deferred',
         parentSessionID: 'parent-1',
@@ -805,10 +817,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({

+ 27 - 24
src/multiplexer/session-manager.ts

@@ -6,12 +6,15 @@ import {
   isServerRunning,
   type Multiplexer,
 } from '../multiplexer';
-import type {
-  BackgroundJobBoard,
-  BackgroundJobState,
-} from '../utils/background-job-board';
+import type { BackgroundJobState } from '../utils/background-job-board';
+import type { BackgroundJobStore } from '../utils/background-job-store';
 import { log } from '../utils/logger';
 
+type BackgroundJobReader = Pick<
+  BackgroundJobStore,
+  'getState' | 'deferIfRunning' | 'clearDeferredClose'
+>;
+
 interface TrackedSession {
   sessionId: string;
   paneId: string;
@@ -32,7 +35,6 @@ interface SharedSessionState {
   knownSessions: Map<string, KnownSession>;
   spawningSessions: Set<string>;
   closingSessions: Map<string, Promise<void>>;
-  deferredIdleCloses: Set<string>;
 }
 
 interface SessionEvent {
@@ -65,7 +67,6 @@ function getSharedState(): SharedSessionState {
     knownSessions: new Map(),
     spawningSessions: new Set(),
     closingSessions: new Map(),
-    deferredIdleCloses: new Set(),
   };
 
   return globalWithState[SHARED_STATE_KEY];
@@ -77,7 +78,6 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
-  state.deferredIdleCloses.clear();
 }
 
 /**
@@ -95,21 +95,19 @@ export class MultiplexerSessionManager {
   private knownSessions: SharedSessionState['knownSessions'];
   private spawningSessions: SharedSessionState['spawningSessions'];
   private closingSessions: SharedSessionState['closingSessions'];
-  private deferredIdleCloses: SharedSessionState['deferredIdleCloses'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
 
   constructor(
     ctx: PluginInput,
     config: MultiplexerConfig,
-    private readonly backgroundJobBoard?: BackgroundJobBoard,
+    private readonly backgroundJobBoard?: BackgroundJobReader,
   ) {
     const sharedState = getSharedState();
     this.sessions = sharedState.sessions;
     this.knownSessions = sharedState.knownSessions;
     this.spawningSessions = sharedState.spawningSessions;
     this.closingSessions = sharedState.closingSessions;
-    this.deferredIdleCloses = sharedState.deferredIdleCloses;
 
     this.directory = ctx.directory;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
@@ -284,7 +282,7 @@ export class MultiplexerSessionManager {
 
     if (statusType) {
       if (statusType !== 'busy') {
-        this.deferredIdleCloses.delete(sessionId);
+        this.backgroundJobBoard?.clearDeferredClose(sessionId);
         return;
       }
 
@@ -316,7 +314,6 @@ export class MultiplexerSessionManager {
       backgroundJobState: this.backgroundJobState(sessionId),
     });
 
-    this.deferredIdleCloses.delete(sessionId);
     await this.closeSession(sessionId, 'deleted');
   }
 
@@ -368,7 +365,7 @@ export class MultiplexerSessionManager {
         if (!status) continue;
 
         if (status.type !== 'idle') {
-          this.deferredIdleCloses.delete(sessionId);
+          this.backgroundJobBoard?.clearDeferredClose(sessionId);
           continue;
         }
 
@@ -410,10 +407,11 @@ export class MultiplexerSessionManager {
   private async closeSession(
     sessionId: string,
     reason: CloseReason,
+    skipPolicyCheck = false,
   ): Promise<void> {
     if (reason === 'deleted') {
       this.knownSessions.delete(sessionId);
-      this.deferredIdleCloses.delete(sessionId);
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
     }
 
     const existingClose = this.closingSessions.get(sessionId);
@@ -451,8 +449,11 @@ export class MultiplexerSessionManager {
       });
     }
 
-    if (reason === 'idle' && this.isRunningBackgroundJob(sessionId)) {
-      this.deferredIdleCloses.add(sessionId);
+    if (
+      reason === 'idle' &&
+      !skipPolicyCheck &&
+      !this.shouldCloseNow(sessionId)
+    ) {
       log(
         '[multiplexer-session-manager] close skipped; background job running',
         {
@@ -466,7 +467,6 @@ export class MultiplexerSessionManager {
       return;
     }
 
-    this.deferredIdleCloses.delete(sessionId);
     this.sessions.delete(sessionId);
 
     log('[multiplexer-session-manager] closing session pane', {
@@ -580,7 +580,7 @@ export class MultiplexerSessionManager {
         directory: known.directory,
         ownerInstanceId: this.instanceId,
       });
-      this.deferredIdleCloses.delete(sessionId);
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
 
       log('[multiplexer-session-manager] pane respawned on busy', {
         instanceId: this.instanceId,
@@ -616,14 +616,16 @@ export class MultiplexerSessionManager {
     return this.backgroundJobBoard?.getState(sessionId);
   }
 
-  private isRunningBackgroundJob(sessionId: string): boolean {
-    return this.backgroundJobBoard?.isRunning(sessionId) ?? false; // ponytail: intent-revealing query
+  private shouldCloseNow(sessionId: string): boolean {
+    return this.backgroundJobBoard?.deferIfRunning(sessionId) ?? true;
   }
 
-  async retryDeferredIdleClose(sessionId: string): Promise<void> {
+  async closeSessionFromCoordinator(sessionId: string): Promise<void> {
     if (!this.enabled) return;
-    if (!this.deferredIdleCloses.has(sessionId)) return;
-    await this.closeSession(sessionId, 'idle');
+    // Coordinator already vetted lifecycle policy; skip re-check
+    // ponytail: theoretical race if new job starts between coordinator's
+    // retryDeferredClose() and this call, but session IDs are unique per launch
+    await this.closeSession(sessionId, 'idle', true);
   }
 
   async cleanup(): Promise<void> {
@@ -653,7 +655,8 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
-    this.deferredIdleCloses.clear();
+    // ponytail: deferred state lives in coordinator, not here
+    // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
 
     log('[multiplexer-session-manager] cleanup complete');
   }

+ 2 - 2
src/tools/acp-run.ts

@@ -306,7 +306,7 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
       if (!cwd) throw new Error('acp_run requires a working directory');
 
       await ctx.ask({
-        permission: 'bash',
+        permission: 'acp_run',
         patterns: [`${config.command} ${config.args.join(' ')}`.trim()],
         always: [],
         metadata: {
@@ -324,7 +324,7 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
         async (title, metadata) => {
           if (config.permissionMode === 'reject') return;
           await ctx.ask({
-            permission: 'bash',
+            permission: 'acp_run',
             patterns: [`acp:${args.agent}:${title}`],
             always: [],
             metadata,

+ 2 - 2
src/tools/cancel-task.ts

@@ -3,7 +3,7 @@ import {
   type ToolDefinition,
   tool,
 } from '@opencode-ai/plugin';
-import type { BackgroundJobBoard } from '../utils/background-job-board';
+import type { BackgroundJobStore } from '../utils/background-job-store';
 import { isRecord as isObjectRecord } from '../utils/guards';
 import { log } from '../utils/logger';
 import { abortSessionWithTimeout, withTimeout } from '../utils/session';
@@ -12,7 +12,7 @@ const z = tool.schema;
 
 interface CancelTaskToolOptions {
   client: PluginInput['client'];
-  backgroundJobBoard: BackgroundJobBoard;
+  backgroundJobBoard: BackgroundJobStore;
   shouldManageSession: (sessionID: string) => boolean;
   abortTimeoutMs?: number;
   verifyAbortMs?: number;

+ 30 - 0
src/tui.test.ts

@@ -4,6 +4,7 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import {
   getSidebarAgentNames,
+  readCompactSidebar,
   readConfigInvalid,
   splitSidebarModelId,
   default as tuiPlugin,
@@ -119,6 +120,35 @@ describe('readConfigInvalid', () => {
       fs.rmSync(tempDir, { recursive: true, force: true });
     }
   });
+
+  test('uses compact sidebar by default', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      fs.mkdirSync(projectDir, { recursive: true });
+
+      expect(readCompactSidebar(projectDir)).toBe(true);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('allows expanded sidebar config', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      const configDir = path.join(projectDir, '.opencode');
+      fs.mkdirSync(configDir, { recursive: true });
+      fs.writeFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({ compactSidebar: false }),
+      );
+
+      expect(readCompactSidebar(projectDir)).toBe(false);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
 });
 
 describe('tui plugin env disable', () => {

+ 8 - 4
src/tui.ts

@@ -124,10 +124,10 @@ function agentRow(
 function compactAgentRow(
   label: string,
   model: string,
-  variant: string | undefined,
+  _variant: string | undefined,
   theme: { textMuted: unknown },
 ): JSX.Element {
-  const value = variant ? `${model} (${variant})` : model;
+  const modelName = splitSidebarModelId(model).model;
   return box(
     {
       width: '100%',
@@ -136,7 +136,7 @@ function compactAgentRow(
     },
     [
       text({ fg: theme.textMuted, width: 14 }, [label]),
-      text({ fg: theme.textMuted }, [value]),
+      text({ fg: theme.textMuted }, [modelName]),
     ],
   );
 }
@@ -229,7 +229,7 @@ function readConfigState(directory: string): {
       configInvalid = true;
     },
   });
-  const compactSidebar = config.compactSidebar ?? false;
+  const compactSidebar = config.compactSidebar ?? true;
   return { configInvalid, compactSidebar };
 }
 
@@ -237,6 +237,10 @@ export function readConfigInvalid(directory: string): boolean {
   return readConfigState(directory).configInvalid;
 }
 
+export function readCompactSidebar(directory: string): boolean {
+  return readConfigState(directory).compactSidebar;
+}
+
 const plugin: TuiPluginModule & { id: string } = {
   id: `${PLUGIN_NAME}:tui`,
   tui: async (api, _options, meta) => {

+ 16 - 1
src/utils/background-job-board.ts

@@ -1,3 +1,4 @@
+import type { BackgroundJobStore } from './background-job-store';
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
 export interface ContextFile {
@@ -80,7 +81,7 @@ const AGENT_PREFIX: Record<string, string> = {
   oracle: 'ora',
 };
 
-export class BackgroundJobBoard {
+export class BackgroundJobBoard implements BackgroundJobStore {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
   private terminalStateListeners: TerminalStateListener[] = [];
@@ -514,6 +515,20 @@ export class BackgroundJobBoard {
     this.jobs.delete(taskID);
   }
 
+  // ── Lifecycle policy (board = no policy, always close) ───────────
+
+  deferIfRunning(_sessionId: string): boolean {
+    return false; // ponytail: safe default - don't close
+  }
+
+  retryDeferredClose(_sessionId: string): boolean {
+    return false; // Nothing deferred at board level
+  }
+
+  clearDeferredClose(_sessionId: string): void {
+    // No-op at board level
+  }
+
   private trimReusable(taskID: string): void {
     const job = this.jobs.get(taskID);
     if (!job || !isReusable(job)) return;

+ 122 - 0
src/utils/background-job-coordinator.test.ts

@@ -0,0 +1,122 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from './background-job-board';
+import { BackgroundJobCoordinator } from './background-job-coordinator';
+
+function createMockBoard(isRunning = false) {
+  return {
+    isRunning: mock(() => isRunning),
+    getState: mock(() => (isRunning ? 'running' : 'completed')),
+    addTerminalStateListener: mock(() => {}),
+    removeTerminalStateListener: mock(() => {}),
+  } as any;
+}
+
+describe('BackgroundJobCoordinator', () => {
+  test('deferIfRunning returns false when job is running', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+    expect(coordinator.deferIfRunning('ses_123')).toBe(false);
+  });
+
+  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);
+  });
+
+  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);
+  });
+
+  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);
+  });
+
+  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);
+  });
+
+  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');
+  });
+
+  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();
+  });
+
+  test('full chain: board terminal → coordinator → listener for deferred job', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const listener = mock(() => {});
+    coordinator.addTerminalStateListener(listener);
+
+    // Register and start a job
+    board.registerLaunch({
+      taskID: 'full-chain-test',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({
+      taskID: 'full-chain-test',
+      state: 'running',
+    });
+
+    // Defer close while job is running
+    expect(coordinator.deferIfRunning('full-chain-test')).toBe(false);
+
+    // Transition to completed — board fires listener, coordinator re-checks
+    board.updateStatus({
+      taskID: 'full-chain-test',
+      state: 'completed',
+    });
+
+    expect(listener).toHaveBeenCalledWith('full-chain-test');
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+});

+ 238 - 0
src/utils/background-job-coordinator.ts

@@ -0,0 +1,238 @@
+import type {
+  BackgroundJobBoard,
+  BackgroundJobLaunchInput,
+  BackgroundJobRecord,
+  BackgroundJobStatusInput,
+  ContextFile,
+} from './background-job-board';
+import type { BackgroundJobStore } from './background-job-store';
+import type { TaskOutputState } from './task';
+
+type TerminalStateListener = (taskID: string) => void;
+
+/**
+ * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
+ * It sits between the board and its consumers, providing:
+ * - Subscription interface for terminal state notifications (replaces fire-and-forget)
+ * - Lifecycle policy: determines when jobs are terminal, when closes should be deferred
+ * - Single-writer contract: coordinator is the sole writer to the board
+ *
+ * The board's guards prevent silent overwrites. The coordinator adds:
+ * - Centralized notification with guaranteed delivery
+ * - Re-checks board state before notifying (handles races)
+ */
+export class BackgroundJobCoordinator implements BackgroundJobStore {
+  private terminalStateListeners: TerminalStateListener[] = [];
+  // Stores session IDs (which equal task IDs) awaiting close after background job completes
+  private readonly deferredIdleCloses = new Set<string>();
+
+  constructor(private readonly board: BackgroundJobBoard) {
+    // Subscribe to the board's terminal state notifications
+    this.board.addTerminalStateListener((taskID) => {
+      this.handleTerminalState(taskID);
+    });
+  }
+
+  // ── Terminal state notification (guaranteed delivery) ─────────────
+
+  addTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners.push(listener);
+  }
+
+  removeTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners = this.terminalStateListeners.filter(
+      (entry) => entry !== listener,
+    );
+  }
+
+  /**
+   * Handle terminal state from board. Re-checks board state to handle races.
+   * This is the centralized lifecycle policy.
+   */
+  private handleTerminalState(taskID: string): void {
+    // Re-check board state to handle races
+    const state = this.board.getState(taskID);
+    if (state === undefined) return; // Job was already cleaned up
+
+    // 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);
+      }
+    }
+  }
+
+  // ── 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 {
+    if (!this.board.isRunning(sessionId)) {
+      this.deferredIdleCloses.delete(sessionId);
+      return true;
+    }
+    this.deferredIdleCloses.add(sessionId);
+    return false;
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * Clear deferred close state for a session being deleted.
+   */
+  clearDeferredClose(sessionId: string): void {
+    this.deferredIdleCloses.delete(sessionId);
+  }
+
+  // ── Mutation methods (sole writer to board) ──────────────────────
+
+  registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
+    return this.board.registerLaunch(input);
+  }
+
+  updateStatus(
+    input: BackgroundJobStatusInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.updateStatus(input);
+  }
+
+  updateFromStatusOutput(output: string): BackgroundJobRecord | undefined {
+    return this.board.updateFromStatusOutput(output);
+  }
+
+  markRunningFromLiveSession(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markRunningFromLiveSession(taskID, now);
+  }
+
+  markReconciled(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markReconciled(taskID, now);
+  }
+
+  markCancelled(
+    taskID: string,
+    reason?: string,
+    now = Date.now(),
+    options: { force?: boolean } = {},
+  ): BackgroundJobRecord | undefined {
+    return this.board.markCancelled(taskID, reason, now, options);
+  }
+
+  // ── Query methods ────────────────────────────────────────────────
+
+  get(taskID: string): BackgroundJobRecord | undefined {
+    return this.board.get(taskID);
+  }
+
+  field<K extends keyof BackgroundJobRecord>(
+    taskID: string,
+    key: K,
+  ): BackgroundJobRecord[K] | undefined {
+    return this.board.field(taskID, key);
+  }
+
+  isRunning(taskID: string): boolean {
+    return this.board.isRunning(taskID);
+  }
+
+  isTerminalUnreconciled(taskID: string): boolean {
+    return this.board.isTerminalUnreconciled(taskID);
+  }
+
+  getResultSummary(taskID: string): string | undefined {
+    return this.board.getResultSummary(taskID);
+  }
+
+  getLastLiveBusyAt(taskID: string): number | undefined {
+    return this.board.getLastLiveBusyAt(taskID);
+  }
+
+  getParentSessionID(taskID: string): string | undefined {
+    return this.board.getParentSessionID(taskID);
+  }
+
+  getState(taskID: string): TaskOutputState | 'reconciled' | undefined {
+    return this.board.getState(taskID);
+  }
+
+  resolve(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolve(parentSessionID, taskIDOrAlias);
+  }
+
+  resolveReusable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolveReusable(parentSessionID, taskIDOrAlias, agent);
+  }
+
+  resolveRecoverable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolveRecoverable(parentSessionID, taskIDOrAlias, agent);
+  }
+
+  markUsed(parentSessionID: string, key: string, now = Date.now()): void {
+    this.board.markUsed(parentSessionID, key, now);
+  }
+
+  taskIDs(): Set<string> {
+    return this.board.taskIDs();
+  }
+
+  addContext(taskID: string, files: ContextFile[]): void {
+    this.board.addContext(taskID, files);
+  }
+
+  list(parentSessionID?: string): BackgroundJobRecord[] {
+    return this.board.list(parentSessionID);
+  }
+
+  hasRunning(parentSessionID: string): boolean {
+    return this.board.hasRunning(parentSessionID);
+  }
+
+  hasTerminalUnreconciled(parentSessionID: string): boolean {
+    return this.board.hasTerminalUnreconciled(parentSessionID);
+  }
+
+  hasConvergenceSignals(taskID: string, threshold = 3): boolean {
+    return this.board.hasConvergenceSignals(taskID, threshold);
+  }
+
+  formatForPrompt(
+    parentSessionID: string,
+    now = Date.now(),
+  ): string | undefined {
+    return this.board.formatForPrompt(parentSessionID, now);
+  }
+
+  clearParent(parentSessionID: string): void {
+    this.board.clearParent(parentSessionID);
+  }
+
+  drop(taskID: string): void {
+    this.board.drop(taskID);
+  }
+}

+ 79 - 0
src/utils/background-job-store.ts

@@ -0,0 +1,79 @@
+import type {
+  BackgroundJobLaunchInput,
+  BackgroundJobRecord,
+  BackgroundJobStatusInput,
+  ContextFile,
+} from './background-job-board';
+import type { TaskOutputState } from './task';
+
+/**
+ * Unified interface for background job operations.
+ * Both BackgroundJobBoard and BackgroundJobCoordinator satisfy this.
+ *
+ * ponytail: single interface, both board and coordinator implement it.
+ */
+export interface BackgroundJobStore {
+  // ── Mutation methods ──────────────────────────────────────────────
+  registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord;
+  updateStatus(
+    input: BackgroundJobStatusInput,
+  ): BackgroundJobRecord | undefined;
+  updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
+  markRunningFromLiveSession(
+    taskID: string,
+    now?: number,
+  ): BackgroundJobRecord | undefined;
+  markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
+  markCancelled(
+    taskID: string,
+    reason?: string,
+    now?: number,
+    options?: { force?: boolean },
+  ): BackgroundJobRecord | undefined;
+  clearParent(parentSessionID: string): void;
+  drop(taskID: string): void;
+  addContext(taskID: string, files: ContextFile[]): void;
+  markUsed(parentSessionID: string, key: string, now?: number): void;
+
+  // ── Query methods ─────────────────────────────────────────────────
+  get(taskID: string): BackgroundJobRecord | undefined;
+  field<K extends keyof BackgroundJobRecord>(
+    taskID: string,
+    key: K,
+  ): BackgroundJobRecord[K] | undefined;
+  isRunning(taskID: string): boolean;
+  isTerminalUnreconciled(taskID: string): boolean;
+  getResultSummary(taskID: string): string | undefined;
+  getLastLiveBusyAt(taskID: string): number | undefined;
+  getParentSessionID(taskID: string): string | undefined;
+  getState(taskID: string): TaskOutputState | 'reconciled' | undefined;
+  resolve(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+  ): BackgroundJobRecord | undefined;
+  resolveReusable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined;
+  resolveRecoverable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined;
+  taskIDs(): Set<string>;
+  list(parentSessionID?: string): BackgroundJobRecord[];
+  hasRunning(parentSessionID: string): boolean;
+  hasTerminalUnreconciled(parentSessionID: string): boolean;
+  hasConvergenceSignals(taskID: string, threshold?: number): boolean;
+  formatForPrompt(parentSessionID: string, now?: number): string | undefined;
+
+  // ── 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;
+}

+ 2 - 0
src/utils/index.ts

@@ -1,5 +1,7 @@
 export * from './agent-variant';
 export * from './background-job-board';
+export * from './background-job-coordinator';
+export * from './background-job-store';
 export * from './internal-initiator';
 export { getLogDir, initLogger, log } from './logger';
 export * from './polling';