Browse Source

docs: add implementation plan for Herdr main-vertical (issue #668)

Michael Henke 1 month ago
parent
commit
7be18e32b4
1 changed files with 581 additions and 0 deletions
  1. 581 0
      docs/superpowers/plans/2026-07-07-herdr-main-vertical.md

+ 581 - 0
docs/superpowers/plans/2026-07-07-herdr-main-vertical.md

@@ -0,0 +1,581 @@
+# Herdr main-vertical Layout Fix — Implementation Plan
+
+> **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:** Make Herdr's `main-vertical` multiplexer layout stack subagent panes vertically in a right-side column instead of repeatedly splitting right from the parent.
+
+**Architecture:** Track a single `agentAreaPaneId` (first child in right column). Route subsequent spawns to split down from that pane. Fall back to parent split on stale reference. `closePane` and `applyLayout` clear the tracking.
+
+**Tech Stack:** TypeScript, Bun (test runner), Biome (lint/format). No new dependencies.
+
+## Global Constraints
+
+- Biome formatter: single quotes, 2-space indent, 80-char line width, trailing commas always.
+- Strict TypeScript mode; no explicit `any` (test files exempt).
+- Tests use `bun:test` (describe/test/expect/mock).
+- No new dependencies added (YAGNI).
+- `ponytail:` comment for deliberate simplifications.
+- Commit messages: conventional style, reference issue #668.
+
+---
+
+### Task 1: Add state fields to HerdrMultiplexer
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.ts:31-44` (constructor + fields)
+
+**Interfaces:**
+- Consumes: `MultiplexerLayout` type from `../../config/schema`
+- Produces: `this.layout`, `this.paneDirection`, `this.agentAreaPaneId` for later tasks
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/multiplexer/herdr/index.test.ts` after the existing constructor tests:
+
+```typescript
+test('stores layout from constructor', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+  // @ts-expect-error - accessing private for test
+  expect(herdr.layout).toBe('main-vertical');
+  // @ts-expect-error
+  expect(herdr.agentAreaPaneId).toBeNull();
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts -t "stores layout from constructor"`
+Expected: FAIL with property does not exist
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `src/multiplexer/herdr/index.ts`, change the class fields and constructor:
+
+```typescript
+export class HerdrMultiplexer implements Multiplexer {
+  readonly type = 'herdr' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private readonly parentPaneId = process.env.HERDR_PANE_ID;
+  private readonly layout: MultiplexerLayout;
+  private readonly paneDirection: HerdrPaneDirection;
+  private agentAreaPaneId: string | null = null;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    // Herdr does not support exact main pane sizing like tmux.
+    // Layout config is mapped to pane split direction.
+    void mainPaneSize;
+    this.layout = layout;
+    this.paneDirection = getPaneDirection(layout);
+  }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts -t "stores layout from constructor"`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.ts src/multiplexer/herdr/index.test.ts
+git commit -m "feat(herdr): store layout and add agentAreaPaneId tracking field"
+```
+
+---
+
+### Task 2: Implement main-vertical split logic in spawnPane
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.ts:60-153` (spawnPane method)
+
+**Interfaces:**
+- Consumes: `this.layout`, `this.paneDirection`, `this.agentAreaPaneId`, `this.targetPaneArg()`
+- Produces: updated `spawnPane` with smart split targeting
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `src/multiplexer/herdr/index.test.ts`:
+
+```typescript
+test('main-vertical: 2nd spawn splits agent area down', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  // First spawn creates agent area
+  await herdr.spawnPane('s1', 'Agent 1', 'http://localhost:4096', '/repo');
+  // Second spawn should split from agent area (w1:p2) downward
+  await herdr.spawnPane('s2', 'Agent 2', 'http://localhost:4096', '/repo');
+
+  const splitCommands = commands().filter((c) => c.includes('split'));
+  // 1st: parent → right
+  expect(splitCommands[0]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'right', '--cwd', '/repo', '--no-focus',
+  ]);
+  // 2nd: agent area (w1:p2) → down
+  expect(splitCommands[1]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p2',
+    '--direction', 'down', '--cwd', '/repo', '--no-focus',
+  ]);
+});
+
+test('main-vertical: 3rd spawn splits same agent area down', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+  await herdr.spawnPane('s3', 'A3', 'http://localhost:4096', '/repo');
+
+  const splitCommands = commands().filter((c) => c.includes('split'));
+  // All subsequent spawns target w1:p2 (agent area)
+  expect(splitCommands[2]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p2',
+    '--direction', 'down', '--cwd', '/repo', '--no-focus',
+  ]);
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts -t "main-vertical"`
+Expected: FAIL (direction is 'right' for all)
+
+- [ ] **Step 3: Write minimal implementation**
+
+Replace the split-args construction and split call in `spawnPane` (lines 72-112):
+
+```typescript
+    try {
+      // Determine split target and direction based on layout + agent area state
+      let target: string[];
+      let direction: HerdrPaneDirection;
+      let wasFirstChild = false;
+
+      if (this.layout === 'main-vertical' && this.agentAreaPaneId) {
+        target = [this.agentAreaPaneId];
+        direction = 'down';
+        const agentSplit = await this.runSplit(
+          target,
+          direction,
+          directory,
+        );
+        if (!agentSplit) {
+          log('[herdr] agent area split failed, falling back to parent', {
+            agentAreaPaneId: this.agentAreaPaneId,
+          });
+          this.agentAreaPaneId = null;
+        }
+      }
+
+      if (!this.agentAreaPaneId) {
+        // First child OR fallback after stale agent area
+        target = this.targetPaneArg();
+        direction = this.paneDirection;
+        wasFirstChild = true;
+      }
+
+      let paneId: string | null = null;
+      if (wasFirstChild) {
+        paneId = await this.runSplit(target, direction, directory);
+      } else {
+        // agent area split already ran; re-parse from last split output
+        paneId = this.lastSplitPaneId;
+      }
+
+      if (!paneId) {
+        log('[herdr] spawnPane: could not parse pane_id from output');
+        return { success: false };
+      }
+
+      // 2. Rename the pane for visibility
+      await crossSpawn(
+        [herdr, 'pane', 'rename', paneId, description.slice(0, 30)],
+        { stdout: 'ignore', stderr: 'ignore' },
+      ).exited;
+
+      // 3. Run opencode attach in the new pane
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+      );
+
+      const runProc = crossSpawn([herdr, 'pane', 'run', paneId, opencodeCmd], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const runExitCode = await runProc.exited;
+      if (runExitCode !== 0) {
+        const runStderr = await runProc.stderr();
+        log('[herdr] spawnPane: run failed', {
+          exitCode: runExitCode,
+          stderr: runStderr.trim(),
+        });
+        return { success: false };
+      }
+
+      if (wasFirstChild && this.layout === 'main-vertical') {
+        this.agentAreaPaneId = paneId;
+      }
+
+      log('[herdr] spawnPane: SUCCESS', { paneId });
+      return { success: true, paneId };
+    } catch (err) {
+      log('[herdr] spawnPane: exception', { error: String(err) });
+      return { success: false };
+    }
+```
+
+Add a helper method `runSplit` and field `lastSplitPaneId`:
+
+```typescript
+  private lastSplitPaneId: string | null = null;
+
+  private async runSplit(
+    target: string[],
+    direction: HerdrPaneDirection,
+    directory: string,
+  ): Promise<string | null> {
+    const herdr = await this.getBinary();
+    if (!herdr) return null;
+
+    const splitArgs = [
+      herdr,
+      'pane',
+      'split',
+      ...target,
+      '--direction',
+      direction,
+      '--cwd',
+      directory,
+      '--no-focus',
+    ];
+
+    log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+
+    const splitProc = crossSpawn(splitArgs, {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const splitExitCode = await splitProc.exited;
+    const splitStdout = await splitProc.stdout();
+    const splitStderr = await splitProc.stderr();
+
+    if (splitExitCode !== 0) {
+      log('[herdr] spawnPane: split failed', {
+        exitCode: splitExitCode,
+        stderr: splitStderr.trim(),
+      });
+      return null;
+    }
+
+    const paneId = parsePaneId(splitStdout);
+    this.lastSplitPaneId = paneId;
+    return paneId;
+  }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts`
+Expected: PASS (all herdr tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.ts src/multiplexer/herdr/index.test.ts
+git commit -m "feat(herdr): implement main-vertical agent-area stacking"
+```
+
+---
+
+### Task 3: Handle stale agent area fallback in tests
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.test.ts` (add fallback test)
+
+**Interfaces:**
+- Consumes: `spawnPane`, `closePane`, `agentAreaPaneId` state
+- Produces: verified fallback behavior
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+test('main-vertical: fallback to parent when agent area closed', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  const r1 = await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  // Simulate agent area pane being closed externally
+  await herdr.closePane(r1.paneId!);
+
+  // Next spawn should split from parent (w1:p1) → right, not from stale w1:p2
+  await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+  const splitCommands = commands().filter((c) => c.includes('split'));
+  // 1st: parent → right (w1:p1)
+  expect(splitCommands[0]).toContain('w1:p1');
+  // 2nd (after close): parent → right again (w1:p1), not w1:p2
+  expect(splitCommands[1]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'right', '--cwd', '/repo', '--no-focus',
+  ]);
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts -t "fallback to parent when agent area closed"`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.test.ts
+git commit -m "test(herdr): verify agent-area fallback on close"
+```
+
+---
+
+### Task 4: Update closePane and applyLayout
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.ts:155-209` (closePane + applyLayout)
+
+**Interfaces:**
+- Consumes: `this.agentAreaPaneId`
+- Produces: cleared tracking on close/layout change
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+test('closePane clears agentAreaPaneId when agent area closed', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  const r1 = await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  await herdr.closePane(r1.paneId!);
+
+  // @ts-expect-error - accessing private for test
+  expect(herdr.agentAreaPaneId).toBeNull();
+});
+
+test('closePane does NOT clear agentAreaPaneId for non-agent pane', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  // Close a different pane (simulated)
+  await herdr.closePane('w1:p99');
+
+  // @ts-expect-error
+  expect(herdr.agentAreaPaneId).not.toBeNull();
+});
+
+test('applyLayout clears agentAreaPaneId', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+  await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  await herdr.applyLayout('tiled', 50);
+
+  // @ts-expect-error
+  expect(herdr.agentAreaPaneId).toBeNull();
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts -t "agentAreaPaneId"`
+Expected: FAIL (field not cleared)
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `closePane`, after the guard `if (!paneId || paneId === 'unknown') return true;`:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    if (!paneId || paneId === 'unknown') return true;
+
+    if (paneId === this.agentAreaPaneId) {
+      this.agentAreaPaneId = null;
+    }
+
+    const herdr = await this.getBinary();
+```
+
+In `applyLayout`:
+
+```typescript
+  async applyLayout(
+    _layout: MultiplexerLayout,
+    _mainPaneSize: number,
+  ): Promise<void> {
+    // ponytail: herdr has no rebalancing API; clear agent area so a layout
+    // switch starts fresh from the parent pane.
+    this.agentAreaPaneId = null;
+  }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.ts src/multiplexer/herdr/index.test.ts
+git commit -m "feat(herdr): clear agent-area tracking on close and layout change"
+```
+
+---
+
+### Task 5: Verify non-main layouts unaffected
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.test.ts` (add regression tests)
+
+**Interfaces:**
+- Consumes: existing layout tests
+- Produces: confirmed tiled/main-horizontal unchanged
+
+- [ ] **Step 1: Write the failing tests**
+
+```typescript
+test('tiled layout always splits parent right (unaffected)', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('tiled', 60);
+
+  await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+  const splitCommands = commands().filter((c) => c.includes('split'));
+  expect(splitCommands[0]).toContain('w1:p1');
+  expect(splitCommands[0]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'right', '--cwd', '/repo', '--no-focus',
+  ]);
+  expect(splitCommands[1]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'right', '--cwd', '/repo', '--no-focus',
+  ]);
+});
+
+test('main-horizontal layout always splits parent down (unaffected)', async () => {
+  const { HerdrMultiplexer } = await importFreshHerdr();
+  const herdr = new HerdrMultiplexer('main-horizontal', 60);
+
+  await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+  await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+
+  const splitCommands = commands().filter((c) => c.includes('split'));
+  expect(splitCommands[0]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'down', '--cwd', '/repo', '--no-focus',
+  ]);
+  expect(splitCommands[1]).toEqual([
+    '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+    '--direction', 'down', '--cwd', '/repo', '--no-focus',
+  ]);
+});
+```
+
+- [ ] **Step 2: Run tests to verify they pass**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.test.ts
+git commit -m "test(herdr): verify non-main layouts unaffected by agent-area tracking"
+```
+
+---
+
+### Task 6: Update documentation
+
+**Files:**
+- Modify: `docs/multiplexer-integration.md` (Herdr layout section)
+
+**Interfaces:**
+- Consumes: spec documentation requirements
+- Produces: accurate Herdr main-vertical description
+
+- [ ] **Step 1: Read current docs section**
+
+Read `docs/multiplexer-integration.md` lines covering Herdr layout behavior.
+
+- [ ] **Step 2: Update Herdr layout description**
+
+Find the table or text describing Herdr `main-vertical` mapping. Replace with:
+
+```markdown
+#### Herdr Layout Behavior
+
+| Layout | Herdr behavior |
+|--------|----------------|
+| `main-vertical` | Parent OpenCode pane stays on the left. First subagent opens in a right-side pane; subsequent subagents stack vertically in that right column. Parent pane remains dominant. |
+| `main-horizontal` | Each subagent splits below the parent (down). |
+| `even-horizontal` / `tiled` | Each subagent splits to the right of the parent. |
+| `even-vertical` | Each subagent splits below the parent. |
+
+**Note:** Herdr has no layout rebalancing API like tmux's `select-layout`.
+The `main_pane_size` config is ignored. The `main-vertical` layout approximates
+tmux's behavior by tracking the first right-side pane and stacking later agents
+vertically within it. If the agent-area pane is closed, the next spawn
+re-creates it from the parent.
+```
+
+- [ ] **Step 3: Verify docs render**
+
+Run: `bun run check:ci` (no doc lint, but confirms no broken markdown in build)
+Expected: clean
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/multiplexer-integration.md
+git commit -m "docs: describe actual Herdr main-vertical behavior (issue #668)"
+```
+
+---
+
+### Task 7: Final verification
+
+**Files:**
+- All modified files
+
+**Interfaces:**
+- Consumes: all prior tasks
+- Produces: green CI
+
+- [ ] **Step 1: Run full test suite**
+
+Run: `bun test src/multiplexer/herdr/index.test.ts`
+Expected: all herdr tests PASS
+
+- [ ] **Step 2: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: no errors
+
+- [ ] **Step 3: Run lint/format check**
+
+Run: `bun run check:ci`
+Expected: clean
+
+- [ ] **Step 4: Commit if any fixes needed**
+
+```bash
+git add -A
+git commit -m "fix(herdr): address lint/type issues from main-vertical implementation"
+```