# Worktree fan-out board (parallel agent lanes) **Feature id:** `w5-fanout-board` · **Branch base:** `develop` · **Effort: L** · Depends on Wave 2 (PTY-inject / `initialInput`) + Wave 4 (worktree create + remove). Fan **one task** across **N** branch/agent lanes of one repo: create N worktrees, spawn N Claude sessions each pre-injected with the same prompt, watch them race side-by-side in the existing split-grid board, approve/kill per lane, then **keep the winner** (its tab + worktree stay) while **losers get worktree-remove**. This is **≈90% composition of shipped parts**; the only genuinely new code is a small pure launch-command builder, an in-memory (localStorage-persisted) lane-group model, and one thin read-only grouping endpoint. > **Byte-shuttle + no-clobber preserved:** the server never learns "fan-out" semantics on the terminal stream. Each lane is its **own worktree → own PTY** (`createWorktree` at `src/http/worktrees.ts:224` gives a fresh dir; `attach(null)` spawns a fresh PTY there), so two Claudes editing the same repo never touch the same working tree. ## New vs reused | Concern | Reused (no change) | New (thin) | |---|---|---| | Spawn N worktrees | `createWorktree` (`worktrees.ts:224`) via `POST /projects/worktree` (`server.ts:908`) | orchestrator loops the route N× (sequential) | | Spawn N sessions w/ prompt | `openProject`→`addEntry`→`initialInput` (`tabs.ts:836,704,717`) → typed after `attached` (`terminal-session.ts:374`, `INITIAL_INPUT_DELAY_MS=700` `:30`); `claude ""` pattern proven at `projects.ts:958` | `buildFanoutCmd(prompt,mode)` — pure, shell-quotes the prompt | | Watch board | `setGridLayout('grid-4'\|'grid-6')` (`tabs.ts:1020`), per-quadrant approve/maximize/monitor (`renderInlineApprove`/`toggleMaximize`/`toggleMonitor` `tabs.ts:1364,1081,1088`), statusLine gauges (`renderCell` `:1328`, `tab-gauge` `:1451`) | per-cell "🏆 Keep" button (same pattern as `cell-max` wiring `tabs.ts:776-801`) | | Discard losers | `removeWorktreeReq`/`confirmAndRemoveWorktree` (`projects.ts:299,349`) → `DELETE /projects/worktree` (`server.ts:937`) | batch-confirm loop in `keepFanoutWinner` | | Grouped discovery | `manager.list()`/`GET /live-sessions` (`manager.ts:185`, `server.ts:332`); `LiveSessionInfo.cwd` (`types.ts:299`) | `GET /live-sessions/grouped` + pure `groupSessionsByRepo` | **Merge of the winner:** the winner's session/worktree **stays open** so the user runs the merge inside it (the ROADMAP + task design collapse "keep winner" to "winner session stays"). A one-click merge is **out of scope for v1** (needs conflict handling); an optional thin `merge()` in `git-ops.ts` is sketched under Effort as a follow-up. --- ## Contract ### New HTTP route (`src/server.ts`) — the one thin server piece | Method / path | Guard | Response | |---|---|---| | `GET /live-sessions/grouped` | none (read-only aggregate, same threat model as `/live-sessions` `:332` and `/digest` `:340`) | `200 SessionGroup[]` | Pure read over `manager.list()`; **no** Origin guard, **no** new write path. Grouping is **string-only** (no `git` exec): fan-out worktrees always live under `-worktrees/` (createWorktree base, `worktrees.ts:151-152`), so sessions whose `cwd` shares that parent cluster into one group. Registered beside `/live-sessions` (`server.ts:332`). ### `src/types.ts` (coordination edit — the frozen shared contract) ```ts // group of running sessions sharing a repo/worktree-root (fan-out discovery) export interface SessionGroup { repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd) label: string; // basename(repoRoot) sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order) } // launch options the FE passes to TabApp.launchFanout (FE-internal, but typed shared) export interface FanoutLaunchOpts { prompt: string; lanes: number; // 2..maxFanoutLanes branchBase: string; // slug; lanes get `${branchBase}-lane-${i}` mode?: PermissionMode; // reuse types.ts:445 } ``` Add `maxFanoutLanes?: number` to **`UiConfig`** (`types.ts:656`) so the FE stepper max is server-controlled. ### `src/config.ts` env var (additive, optional) | Env | Field (add to `Config` `types.ts:26` block) | Default | Parser | |---|---|---|---| | `MAX_FANOUT_LANES` | `maxFanoutLanes: number` | `6` (matches `grid-6` capacity, `grid-layout.ts:30`) | `parseNonNegativeInt` (`config.ts:83`, as `maxSessions` `:297`) | Effective N is `min(opts.lanes, cfg.maxFanoutLanes, 6, maxSessions − liveCount)` — bounded by grid capacity and the DoS cap (`assertUnderSessionCap`, `manager.ts:106`). ### Client-side contract - **No new `ClientMessage`/`ServerMessage`.** The stream stays a byte-shuttle. Launch = N existing `POST /projects/worktree` + N existing WS attaches (via `openProject`). Discard = existing `DELETE /projects/worktree`. - **New `ProjectsHooks` method** (`projects.ts:39`): `onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void` — mirrors the existing `onOpenProject` hook exactly; wired in `tabs.ts` constructor (`:180`) to `this.launchFanout(...)`. - **New pure launch-cmd builder** `buildFanoutCmd(prompt, mode, allowAutoMode): string` → `claude [--permission-mode ] ''\r`. Single-quote wrap with `'` → `'\''` escaping; collapse `\r?\n`→space; cap length (`FANOUT_PROMPT_MAX = 4000`). Reuses `resolveMode`/`buildClaudeCmd` shape (`tabs.ts:659,664`). --- ## Files to change | Path | Concrete change | |---|---| | `src/types.ts` | **Coordination edit.** Add `SessionGroup`, `FanoutLaunchOpts`; add `maxFanoutLanes?: number` to `UiConfig` (`:656`); add `maxFanoutLanes: number` to `Config` (`:26` block). | | `src/config.ts` | Parse `MAX_FANOUT_LANES` in `loadConfig` (helper exists, `parseNonNegativeInt` `:83`); include in returned `Config`. | | `src/http/session-groups.ts` **(new)** | Pure `deriveRepoRoot(cwd: string \| null): string \| null` (strip a trailing `/` when the parent basename ends with `-worktrees`, else return cwd) + `groupSessionsByRepo(sessions: LiveSessionInfo[]): SessionGroup[]`. No I/O — fully node-unit-testable. High-cohesion small file (per coding-style). | | `src/server.ts` | Register `GET /live-sessions/grouped` → `res.json(groupSessionsByRepo(manager.list()))` beside `:332`; import `groupSessionsByRepo`; add `maxFanoutLanes` to the `UiConfig` object at `/config/ui` (`:1099`). | | `public/fanout.ts` **(new)** | `buildFanoutCmd(prompt, mode, allowAutoMode)`, `shellSingleQuote(s)`, `sanitizePrompt(s)` (collapse newlines, trim, cap), `laneBranch(base, i)`, `slugify(prompt)` — all pure/exported for jsdom unit tests. Plus `createWorktreeReq(repoPath, branch)` thin POST helper if not reusing the inline one in `projects.ts:725` (extract it to reuse — see below). | | `public/projects.ts` | Add `onFanout` to `ProjectsHooks` (`:39`); add `renderFanoutForm(detail, hooks)` (sibling of `renderNewWorktreeForm` `:692`: prompt `