Browse Source

merge: resolve tmux closePane conflict via gracefulClosePane refactor; fix test fmt

Michael Henke 1 month ago
parent
commit
ba4a043414

+ 28 - 0
.out-of-scope/hashline.md

@@ -0,0 +1,28 @@
+# Hashline (content-hash line anchors for LLM edits)
+
+This project does not implement hashline / content-hash line anchoring for LLM edits.
+
+## Why this is out of scope
+
+Hashline is a technique where each line returned by the `read` tool is prefixed
+with a short content-hash anchor (e.g. `9#KT:  console.log(...)`), and the LLM
+references edits by `LINE#HASH` anchor instead of quoting raw text. The system
+validates the hash before applying an edit, so if the file changed between read
+and edit the hash mismatches and the edit is rejected before it can corrupt
+anything. Hashes are context-based (`xxh32(prev + curr + next)` over a 16-char
+alphabet), so editing line N only invalidates N-1/N/N+1.
+
+Implementing it requires wrapping OpenCode's core `read` and `edit` tools to
+inject and validate anchors and track file snapshots for stale-anchor recovery.
+That is a deep, behavior-changing modification to the fundamental edit loop —
+fragile to bolt onto a slim plugin that intentionally avoids reimplementing tool
+plumbing. It belongs in OpenCode core itself or a dedicated standalone plugin,
+not in oh-my-opencode-slim.
+
+Token savings are real (reported ~61% fewer output tokens on Grok 4 Fast, ~8%
+better on Gemini), but the integration cost and architectural fit put it
+outside this project's scope.
+
+## Prior requests
+
+- #141 — "Discussion about hashline" (feature proposal / discussion; closed as wontfix)

+ 30 - 0
.out-of-scope/preset-fallback.md

@@ -0,0 +1,30 @@
+# Preset Fallback & Preset-Scoped Mode
+
+This project does not support preset-to-preset fallback or preset-scoped mode.
+
+## Why this is out of scope
+
+Model-level fallback already exists in the plugin: when an agent's `model` is
+configured as an array, the entries form a fallback chain resolved at runtime by
+`ForegroundFallbackManager` (abort the failed session, re-prompt with the next
+untried model). Subagents not listed in the active preset also inherit the
+preset's primary model. So the runtime "if my model is unavailable, try another"
+surface is already covered.
+
+What was requested goes further and is a different shape:
+
+- **Preset-to-preset fallback** — a preset declaring it falls back to another
+  preset (e.g. `PresetSchema` gaining a `fallback`/`extends` field). This needs
+  schema changes plus resolution wiring in the preset manager and config hook,
+  and raises questions about which agents/settings the fallback preset supplies.
+- **Preset-scoped mode** — restricting a preset to certain agents, directories,
+  tasks, or conversation modes (a `scope` field on `PresetSchema`). This is a
+  meaningful design surface with no current implementation (zero matches in the
+  codebase) and no agreed semantics.
+
+The maintainer chose not to take on that design/implementation as `wontfix`
+(issue #638). If the need recurs with a concrete design, revisit.
+
+## Prior requests
+
+- #638 — "Support preset fallback and document preset-scoped mode"

+ 91 - 0
CONTEXT.md

@@ -0,0 +1,91 @@
+# CONTEXT.md — Domain Glossary
+
+A glossary of the terms used in this project's domain. Definitions describe what a term means, not how it is implemented.
+
+## Agents
+
+- **Agent** — A named LLM role with a defined lane (permissions, tools, prompt); the unit of work delegation in the system.
+- **Orchestrator** — The primary agent. Plans work, delegates to subagents, monitors them, and reconciles their results. One per session; cannot be disabled.
+- **Subagent** — A specialist agent the orchestrator delegates bounded work to.
+- **Explorer** — Subagent for fast codebase search and pattern matching.
+- **Librarian** — Subagent for external documentation and library research.
+- **Oracle** — Subagent for architecture, debugging strategy, and code review.
+- **Designer** — Subagent for UI/UX design and visual polish.
+- **Fixer** — Subagent for bounded implementation and execution.
+- **Observer** — Subagent for visual/media analysis (images, PDFs, diagrams). Disabled by default.
+- **Council** — A multi-LLM agent that runs several councillors and synthesizes their views.
+- **Councillor** — A read-only LLM advisor spawned by the council; hidden from @-mention autocomplete. Cannot be disabled.
+- **Agent mode** — SDK classification of an agent: `primary` (orchestrator), `subagent` (specialist), or `all` (council, both user-facing and delegatable).
+- **Protected agent** — An agent that cannot be disabled (orchestrator, councillor).
+- **Custom agent** — A user-defined agent supplied via config, distinct from the built-ins.
+- **ACP agent** — An external agent defined via the Agent Communication Protocol, run through `acp_run`.
+- **Display name** — A user-assignable name shown in @-mentions; may differ from the internal agent name.
+- **Agent alias** — A legacy or alternate name that maps to a built-in agent. Rejected synonyms: `explore` (use `explorer`), `frontend-ui-ux-engineer` (use `designer`).
+
+## Council
+
+- **Consensus** — The synthesized conclusion of a council run, rated `unanimous`, `majority`, or `split`.
+- **Council preset** — A named lineup of councillor configurations used for a council run. Plugin config uses `preset` for the selected agent-override set; council config uses `default_preset` for the selected councillor lineup — the `default_` prefix disambiguates the active selection from the preset list within the council sub-object.
+- **Councillor execution mode** — Whether councillors run `parallel` (default) or `serial`.
+- **Councillor retries** — The number of retries for a councillor that returns an empty response.
+
+## Multiplexer & Sessions
+
+- **Multiplexer** — A terminal backend (tmux, zellij, or herdr) that hosts child agent panes. Set via \`multiplexer.type\`, which also accepts \`auto\` (auto-detect) and \`none\` (disabled).
+- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, or `none`.
+- **Pane** — A terminal region spawned by the multiplexer to run a child agent session.
+- **Child session** — A background agent session hosted in a multiplexer pane and tracked by the session manager.
+- **Session manager** — Tracks child sessions, spawns and closes multiplexer panes, and reacts to session lifecycle events. Note: `TmuxSessionManager` is a deprecated alias — use `MultiplexerSessionManager`.
+- **Close reason** — Why a pane is closed: `idle` or `deleted`.
+
+## Background Jobs
+
+- **Background job** — A delegated specialist task that runs asynchronously; tracked until its result is reconciled into the orchestrator's response.
+- **Background Job Board** — The store of background job state and metadata.
+- **Background Job Coordinator** — The layer that owns background-job lifecycle policy and deferred-close state, writing through the board.
+- **Job state** — A background job's status: `running`, `completed`, `error`, `cancelled`, or `reconciled`. `reconciled` is a distinct post-consumption phase marking that a terminal job's result has been folded into the orchestrator's response; it is not a terminal outcome itself.
+- **Job alias** — A short human-readable identifier for a background job (e.g., `fix-1`, `exp-2`).
+- **Terminal state** — A job state from which no further transition occurs (`completed`, `error`, `cancelled`).
+
+## Skills
+
+- **Skill** — A bundled, self-contained workflow or capability shipped with the plugin. Bundled skills: codemap, clonedeps, simplify, deepwork, reflect, worktrees, oh-my-opencode-slim, release-smoke-test. Note: `loop-engineering` exists on disk but is not registered as a bundled skill.
+
+## Hooks
+
+- **Hook** — A plugin extension point that reacts to OpenCode lifecycle events (e.g., apply-patch, filter-available-skills, loop-command, session-lifecycle).
+
+## Loop
+
+- **Loop** — An auto-iterative run that executes work with an agent, verifies it against success criteria, and repeats until done or escalated.
+- **Loop session** — The state of one loop run (goal, current phase, attempts, history).
+- **Loop phase** — A stage of a loop: `executing`, `verifying`, `done`, `escalated`, or `cancelled`.
+- **Execute agent** — The agent that performs loop work (`fixer`, `designer`, `explorer`, or `librarian`).
+- **Verify agent** — The agent or strategy that verifies loop output (`oracle`, `observer`, or `test`).
+- **Success criterion** — A check that decides whether a loop iteration passed (test, build, lint, fileExists, command, oracle, observer, or manual).
+
+## Interview
+
+- **Interview** — A question/answer flow that builds a persistent specification document from an idea.
+- **Spec block** — A named section within a generated specification document.
+- **Interview dashboard** — The web UI for managing an interview and entering answers.
+
+## Companion
+
+- **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.
+
+## Config
+
+- **Plugin config** — The user-facing configuration loaded from `oh-my-opencode-slim.jsonc`.
+- **Preset** — A named set of per-agent overrides. The same word also names council councillor lineups (see Flagged).
+- **Model entry** — A normalized model reference with an optional variant, used in fallback chains.
+- **Variant** — An optional model qualifier (e.g., a preview build) used in fallback resolution.
+- **Fallback / failover** — The mechanism that switches models when a call is rate-limited or returns empty.
+- **Disabled agents** — Agents turned off via config; `observer` is disabled by default.
+
+## Flagged
+
+Terms with genuine but non-blocking collisions or historical drift. Noted for awareness; no change required:
+
+- **"Presets" means two things** — A plugin *preset* is a set of agent overrides; a council *preset* is a lineup of councillor models. Same word, different JSON paths and types; no structural conflict, but easy to confuse.
+- **Config naming convention** — Config keys mix snake_case (`disabled_agents`, `main_pane_size`) with camelCase (`autoUpdate`, `backgroundJobs`) with no documented rule. Historical drift; `disabled_*` keys are uniformly snake_case while the rest is mixed even within sub-objects.

+ 33 - 0
docs/agents/triage-labels.md

@@ -0,0 +1,33 @@
+# Triage Label Mapping
+
+Maps the **canonical triage roles** (defined in the `triage` skill from
+`mattpocock/skills`) to the actual GitHub label strings used in this repo's
+issue tracker. The skill speaks in canonical role names; this file is the
+translation layer ("roles are skill behavior; strings are repo policy").
+
+| Label in `mattpocock/skills` | Label in our tracker | Meaning |
+| ---------------------------- | -------------------- | ------- |
+| `bug`                        | `bug`                | Something is broken |
+| `enhancement`                | `enhancement`        | New feature or improvement |
+| `needs-triage`               | *(unlabeled)*        | Maintainer needs to evaluate |
+| `needs-info`                 | `needs-info`         | Waiting on reporter for more information |
+| `ready-for-agent`            | `good-to-code`       | Fully specified, ready for an AFK agent |
+| `ready-for-human`            | `good-to-code`       | Needs human implementation |
+| `wontfix`                    | `wontfix`            | Will not be actioned |
+
+## Notes
+
+- `needs-triage` has **no label** by design: an unlabeled issue is implicitly in
+  the `needs-triage` state.
+- `ready-for-agent` and `ready-for-human` both map to `good-to-code`. The
+  difference is who implements: an agent picks up `ready-for-agent`; a human
+  implements `ready-for-human`. `status:in-review` is a separate human-review
+  state (for when code already exists and awaits review) — do not apply it to
+  issues that still need implementation.
+- The following repo labels are intentionally **outside** the triage taxonomy
+  and should not be applied by `/triage`:
+  - `confirmed` — maintainer-acknowledged signal after `needs-triage`
+  - `status:in-review` — human review state (optional overlay on `good-to-code`)
+  - `P0` — priority overlay; apply manually alongside any role for urgent items
+  - `release` — release management
+  - `Share Your Thoughts` — open-ended community feedback

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

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

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

@@ -2149,8 +2149,6 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toContain(
       '### Background Job Board',
     );
-    expect(messages.messages[0].parts[0].text).toContain(
-      'child-transform-1',
-    );
+    expect(messages.messages[0].parts[0].text).toContain('child-transform-1');
   });
 });

+ 18 - 46
src/multiplexer/herdr/index.ts

@@ -15,7 +15,12 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+  normalizePathForShell,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 type HerdrPaneDirection = 'right' | 'down';
@@ -70,6 +75,10 @@ export class HerdrMultiplexer implements Multiplexer {
     }
 
     try {
+      // Normalize Windows backslashes→/ so sh -lc (MSYS2) doesn't
+      // corrupt --cwd (issue #568).
+      const attachDir = normalizePathForShell(directory);
+
       // 1. Split the parent pane to create a new one
       const splitArgs = [
         herdr,
@@ -79,7 +88,7 @@ export class HerdrMultiplexer implements Multiplexer {
         '--direction',
         this.paneDirection,
         '--cwd',
-        directory,
+        attachDir,
         '--no-focus',
       ];
 
@@ -121,7 +130,7 @@ export class HerdrMultiplexer implements Multiplexer {
       const opencodeCmd = buildOpencodeAttachCommand(
         sessionId,
         serverUrl,
-        directory,
+        attachDir,
       );
 
       log('[herdr] spawnPane: running attach command', {
@@ -153,50 +162,13 @@ export class HerdrMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const herdr = await this.getBinary();
-    if (!herdr) {
-      log('[herdr] closePane: herdr binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[herdr] closePane: sending Ctrl+C', { paneId });
-      await crossSpawn([herdr, 'pane', 'send-keys', paneId, 'ctrl+c'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      log('[herdr] closePane: closing pane', { paneId });
-      const proc = crossSpawn([herdr, 'pane', 'close', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[herdr] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0 || exitCode === 1) {
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[herdr] closePane: failed (pane may already be closed)', {
-        paneId,
-      });
-      return false;
-    } catch (err) {
-      log('[herdr] closePane: exception', { error: String(err) });
-      return false;
-    }
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(

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

@@ -0,0 +1,146 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+};
+
+const crossSpawnMock = mock(
+  (_args: string[]): SpawnResult => ({
+    exited: Promise.resolve(0),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(''),
+  }),
+);
+
+mock.module('../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+async function importShared() {
+  return import(`./shared?test=${importCounter++}`);
+}
+
+describe('gracefulClosePane', () => {
+  afterEach(() => {
+    crossSpawnMock.mockReset();
+  });
+
+  test('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
+    const calls: string[][] = [];
+
+    crossSpawnMock.mockImplementation((args: string[]) => {
+      calls.push(args);
+      return {
+        exited: Promise.resolve(0),
+        stdout: () => Promise.resolve(''),
+        stderr: () => Promise.resolve(''),
+      };
+    });
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+
+    expect(ok).toBe(true);
+    expect(calls).toHaveLength(2);
+  });
+
+  test('returns true when acceptExitCode1 and exit code is 1', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', 'terminal_1', {
+      ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
+      acceptExitCode1: true,
+    });
+    expect(ok).toBe(true);
+  });
+
+  test('returns false on exit 1 when acceptExitCode1 is false', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+    expect(ok).toBe(false);
+  });
+
+  test('returns emptyPaneReturnsTrue when paneId is empty', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', '', {
+      ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', ''],
+      emptyPaneReturnsTrue: true,
+    });
+    expect(ok).toBe(true);
+    expect(crossSpawnMock.mock.calls).toHaveLength(0);
+  });
+
+  test('returns false when binary is null', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane(null, '%1', {
+      ctrlC: ['x'],
+      close: ['y'],
+    });
+    expect(ok).toBe(false);
+  });
+});
+
+describe('buildOpencodeAttachCommand', () => {
+  test('normalizes Windows backslash paths to forward slashes', async () => {
+    const original = process.platform;
+    Object.defineProperty(process, 'platform', {
+      value: 'win32',
+      configurable: true,
+    });
+    try {
+      const { buildOpencodeAttachCommand } = await importShared();
+      const cmd = buildOpencodeAttachCommand(
+        'sess',
+        'url',
+        'C:\\Users\\foo\\repo',
+      );
+      expect(cmd).toContain('C:/Users/foo/repo');
+    } finally {
+      Object.defineProperty(process, 'platform', {
+        value: original,
+        configurable: true,
+      });
+    }
+  });
+
+  test('leaves non-Windows paths unchanged', async () => {
+    const original = process.platform;
+    Object.defineProperty(process, 'platform', {
+      value: 'linux',
+      configurable: true,
+    });
+    try {
+      const { buildOpencodeAttachCommand } = await importShared();
+      const cmd = buildOpencodeAttachCommand('sess', 'url', '/home/user/repo');
+      expect(cmd).toContain('/home/user/repo');
+    } finally {
+      Object.defineProperty(process, 'platform', {
+        value: original,
+        configurable: true,
+      });
+    }
+  });
+});

+ 55 - 1
src/multiplexer/shared.ts

@@ -12,11 +12,19 @@ export function quoteShellArg(value: string): string {
   return `'${value.replace(/'/g, `'\\''`)}'`;
 }
 
+/** Normalize Windows backslashes to / so sh -lc (MSYS2/Git Bash) doesn't treat them as escape chars. */
+export function normalizePathForShell(directory: string): string {
+  return process.platform === 'win32'
+    ? directory.replace(/\\/g, '/')
+    : directory;
+}
+
 export function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,
   directory: string,
 ): string {
+  const attachDir = normalizePathForShell(directory);
   return [
     'opencode',
     'attach',
@@ -24,7 +32,7 @@ export function buildOpencodeAttachCommand(
     '--session',
     quoteShellArg(sessionId),
     '--dir',
-    quoteShellArg(directory),
+    quoteShellArg(attachDir),
   ].join(' ');
 }
 
@@ -89,3 +97,49 @@ export async function findBinary(
     return null;
   }
 }
+
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}

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

@@ -5,7 +5,11 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 const TMUX_LAYOUT_DEBOUNCE_MS = 150;
@@ -113,59 +117,13 @@ export class TmuxMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId) {
-      log('[tmux] closePane: no paneId provided');
-      return false;
-    }
-
     const tmux = await this.getBinary();
-    if (!tmux) {
-      log('[tmux] closePane: tmux binary not found');
-      return false;
-    }
-
-    try {
-      // Graceful shutdown sequence:
-      // 1. Send Ctrl+C to the pane to trigger graceful termination of child processes
-      // 2. Wait 250ms to allow processes time to handle SIGINT and clean up
-      // 3. Fallback to kill-pane if graceful termination fails or times out
-      // This ensures child processes (e.g., opencode attach sessions) can exit cleanly
-      // before we forcefully terminate the tmux pane.
-      log('[tmux] closePane: sending Ctrl+C', { paneId });
-      const ctrlCProc = crossSpawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      await ctrlCProc.exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Kill the pane
-      log('[tmux] closePane: killing pane', { paneId });
-      const proc = crossSpawn([tmux, 'kill-pane', '-t', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0) {
-        // Rebalance panes after bursts of child sessions settle.
-        this.scheduleLayout();
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[tmux] closePane: failed (pane may already be closed)', { paneId });
-      return false;
-    } catch (err) {
-      log('[tmux] closePane: exception', { error: String(err) });
-      return false;
-    }
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
   }
 
   async applyLayout(

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

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

+ 4 - 1
src/tui.ts

@@ -177,7 +177,10 @@ function renderSidebar(
         [
           box(
             { paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent },
-            [text({ fg: theme.background }, ['OMO-Slim'])],
+            // Use theme.text, not theme.background: when the theme background is
+            // "none" (transparent) the foreground becomes RGBA(0,0,0,0) and the
+            // badge text vanishes. See #582.
+            [text({ fg: theme.text }, ['OMO-Slim'])],
           ),
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],