20 KiB
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 (
createWorktreeatsrc/http/worktrees.ts:224gives 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 "<prompt>" 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 <repo>-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)
// 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 existingPOST /projects/worktree+ N existing WS attaches (viaopenProject). Discard = existingDELETE /projects/worktree. - New
ProjectsHooksmethod (projects.ts:39):onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void— mirrors the existingonOpenProjecthook exactly; wired intabs.tsconstructor (:180) tothis.launchFanout(...). - New pure launch-cmd builder
buildFanoutCmd(prompt, mode, allowAutoMode): string→claude [--permission-mode <m>] '<shell-quoted, newline-collapsed prompt>'\r. Single-quote wrap with'→'\''escaping; collapse\r?\n→space; cap length (FANOUT_PROMPT_MAX = 4000). ReusesresolveMode/buildClaudeCmdshape (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 /<name> 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 <textarea>, N <input type=number min=2 max=maxFanoutLanes>, branch-base <input> prefilled slugify(prompt), permission-mode <select>, "⑃ Fan out N lanes" submit) rendered in renderProjectDetail (:822) just after the New Worktree form (:930); extract the inline POST /projects/worktree body (:725-737) into an exported createWorktreeReq(repoPath, branch) so fan-out and the form share it (DRY). |
public/tabs.ts |
Add launchFanout(repoPath, repoName, opts) + keepFanoutWinner(groupId, winnerSessionId); a fanoutGroups: Map<string, FanoutGroup> field (persisted via a new FANOUT_KEY); extend TabEntry with fanoutGroupId?: string, worktreePath?: string, branch?: string; append a per-cell 🏆 Keep button in addEntry (:776-801) shown only when entry.fanoutGroupId is set (toggled in renderCell :1328); wire onFanout in the constructor (:180). Reuse setGridLayout (:1020), addEntry (:704), countOpenWithTitlePrefix (:849), removeWorktreeReq (projects.ts:299). |
public/styles.css (or the FE CSS entry) |
.proj-fanout-form, .cell-keep (mirror .cell-max tabs.ts:779), .fanout-banner. No new layout — reuses lay-grid-4/6 + term-cell chrome. |
launchFanout algorithm (FE orchestration, sequential — git worktree add takes a repo lock, so parallel adds race):
- Clamp
N = min(opts.lanes, maxFanoutLanes, 6); validatebranchBaseviavalidateBranchNameClient(projects.ts:672); buildcmd = buildFanoutCmd(prompt, mode, allowAutoMode)once. for i in 1..N:const r = await createWorktreeReq(repoPath, laneBranch(base,i)); onr.okpush{branch, worktreePath:r.path}to the group; on failure record the error, continue (partial-success, surfaced in the banner — no auto-rollback in v1).- If ≥1 lane created: create a
FanoutGroup {id, repoPath, repoName, prompt, lanes[]}; for each created laneaddEntry(null,${repoName}·${branch}, worktreePath, cmd)(reuseopenProject's exact addEntry call shape:842), tag the returnedTabEntrywithfanoutGroupId/worktreePath/branch; persist. setGridLayout(N <= 4 ? 'grid-4' : 'grid-6')andactivatethe first lane. The board's existing per-quadrant approve/maximize/monitor + gauges now cover every lane for free.
keepFanoutWinner(groupId, winnerSessionId):
- Resolve the group; single confirm:
"Keep <winner branch> and discard the other N−1 lanes (delete their worktrees)?". - For each losing lane:
closeTab(idx)(detach — PTY reaped by IDLE_TTL) thenremoveWorktreeReq(repoPath, lane.worktreePath, false); on409dirty →removeWorktreeReq(..., true)(already inside the batch confirm — no per-lane prompt). Collect failures into the banner. - Winner tab stays; drop the group from
fanoutGroups; if all losers gone,setGridLayout('single')and activate the winner.
TDD steps (ordered — RED→GREEN, matching repo style)
Backend pure — test/http/session-groups.test.ts (new, node env, no I/O; mirror the pure-helper style of test/http/worktrees*.test.ts):
deriveRepoRoot('/a/proj-worktrees/lane-1')→/a/proj(parent basename ends-worktrees).deriveRepoRoot('/a/proj')→/a/proj.deriveRepoRoot(null)→null. → implement.groupSessionsByRepo([...]): two sessions with cwds/a/proj-worktrees/lane-1and/lane-2→ oneSessionGroup(repoRoot:/a/proj, 2 members); an unrelated/b/othercwd → its own group;cwd:null→ skipped or an "ungrouped" bucket (decide + assert). Members preservemanager.listnewest-first order.
Backend config — test/config.test.ts (extend): assert maxFanoutLanes default 6, MAX_FANOUT_LANES=3 override parses, -1 throws (fail-fast, like the MAX_SESSIONS test). Update every all-fields CFG fixture (grep -rl "maxSessions" test/) to add maxFanoutLanes (compile gate).
Backend route — test/integration/*.test.ts (extend an existing live-sessions integration file; reuse startServer + fetch, itPty where a real session is needed):
3. GET /live-sessions/grouped on an empty manager → 200 []. No Origin header required (read-only) — assert it does not 403.
4. itPty: attach two sessions with cwds under one *-worktrees dir → grouped returns one group with both ids. GET /config/ui includes maxFanoutLanes.
FE pure — test/fanout.test.ts (new, jsdom or node; pure functions):
5. shellSingleQuote("it's ok") → 'it'\''s ok'. sanitizePrompt("a\nb\r\nc") → "a b c"; over-length → truncated to FANOUT_PROMPT_MAX.
6. buildFanoutCmd("fix bug","plan",true) → claude --permission-mode plan 'fix bug'\r; mode:'default' → claude 'fix bug'\r; mode:'auto', allowAutoMode:false → downgraded to no --permission-mode (SEC-M5 parity with resolveMode tabs.ts:659).
7. laneBranch('feat-x',3) → feat-x-lane-3; result passes validateBranchNameClient (projects.ts:672).
FE component — test/projects.test.ts (extend; reuse makeDetail/makeHooks, stubbed fetch):
8. renderFanoutForm renders a prompt textarea, N stepper (max = maxFanoutLanes), branch-base input, mode select, submit. Empty prompt → submit disabled / inline error via textContent (SEC-L3/H6, like renderNewWorktreeForm :708).
9. Submitting calls hooks.onFanout(detail.path, detail.name, {prompt, lanes, branchBase, mode}).
FE orchestration — test/tabs.test.ts (extend; the file already stubs TerminalSession/fetch):
10. launchFanout(repo,'repo',{lanes:3,...}) with fetch stubbed to return {ok:true,path:'/wt/lane-i'} → 3 addEntry calls (assert 3 tabs), each with the same initialInput (buildFanoutCmd output) and its lane cwd; grid becomes grid-4; worktree POSTs happen sequentially (assert call order / that the (k+1)th starts after the kth resolves).
11. Partial failure: 2nd createWorktreeReq rejects → 2 lanes created, banner shows the failure, no throw (never-throw discipline, projects.ts:299).
12. keepFanoutWinner(id, winnerId): confirm=()=>true, fetch {ok:true} → loser tabs closed (assert tabs.length drops to 1), DELETE /projects/worktree called once per loser with the loser's worktreePath; winner tab remains; layout → single.
13. Dirty loser: DELETE first {ok:false,status:409} then {ok:true} → second call body has force:true; no second window.confirm (batch already confirmed).
14. confirm=()=>false → no closeTab, no DELETE (no accidental deletion).
Run npm test; the ~80% gate holds — grouping/config/route are deterministic node tests, and every FE branch (build-cmd modes, sequential launch, partial failure, keep-winner happy/dirty/cancel) is jsdom-exercised. The only PTY-real assertion (grouped over live sessions) is itPty-gated (auto-skips in sandbox, runs in CI).
Edge cases
- Prompt with quotes /
$/ backticks /;— single-quote wrapping +'\''escaping means the shell passes it verbatim toclaudeas one argv element; no command injection into the shell (see Security). - Multi-line prompt — a raw newline typed into the PTY submits early;
sanitizePromptcollapses\r?\n→space (single-line task descriptions are the use case, like an issue title). Documented in the form's helper text. - N exceeds caps — clamped to
min(maxFanoutLanes, 6, maxSessions−liveCount); if the DoS cap is hit mid-launch,addEntry→attach throws server-side viaassertUnderSessionCap(manager.ts:106, the M4 path) and that lane shows anexit(-1); the banner reports "started K of N". - Branch already checked out / dir exists —
createWorktreereturns409(worktrees.ts:200-208); that lane is skipped with a banner note; other lanes proceed (each branch is-lane-i, so collisions only on a re-run — suggest a freshbranchBase). - Sequential lock contention — awaiting each
createWorktreebefore the next avoids git'sworktree addlock race; total launch is O(N) git adds (bounded, N≤6). - Keep-winner on a dirty loser —
DELETE409 → auto-retry withforce:trueinside the single batch confirm (the user already accepted "discard the other lanes"); still never force-removes the main worktree (server rejectsisMain400,worktrees.ts:327) — losers are never main. - Winner == current worktree you're viewing — fine; only losers are removed. Removing a loser whose tab is elsewhere just detaches then deletes its dir.
- Reload mid-race —
fanoutGroupspersisted toFANOUT_KEY(likeTABS_KEYtabs.ts:57, written inonSessionIdonce ids resolve); on boot, reconstruct the board from persisted groups. Fallback discovery:GET /live-sessions/groupedre-clusters live sessions by repo even if localStorage was cleared. - Session exits (Claude finished) before you pick a winner — the exited lane keeps its last screen (L1 replay,
manager.ts:149) and its gauge greys stale (STATUSLINE_TTL_MS,tabs.ts:64); still selectable as winner (its worktree persists until you keep someone). worktreeEnabled=0— create/remove routes 403 (server.ts:910,939);launchFanoutsurfaces the 403 in the banner and creates nothing (the form can hide itself when/config/uisignals disabled — optional).
Security
- No new trust boundary. Launch reuses
POST /projects/worktreeand each lane's WS attach; discard reusesDELETE /projects/worktree— all alreadyrequireAllowedOrigin-guarded (server.ts:480,909,938).GET /live-sessions/groupedis read-only (session ids, cwds, statuses — same data already in/live-sessions), so no Origin guard, consistent with/live-sessions(:332) and/digest(:340). - Prompt → shell injection (the one new risk). The prompt is typed as raw bytes into the lane's shell before
claudeparses it.shellSingleQuotewraps it in single quotes with'→'\''escaping so the shell treats it as a single literal argv element — no$(...), backtick,;,&&, or redirection can execute.sanitizePromptadditionally strips newlines (which would submit a partial line) and caps length. This is the load-bearing quoting invariant — unit-test it (steps 5-6) and neverString-concat the prompt into the command unquoted. (The threat is limited anyway: the caller already has full shell access via the terminal — this just avoids a surprising early-execution when a prompt contains shell metacharacters.) - Branch names — validated client-side (
validateBranchNameClient:672) and server-side (validateBranchNameworktrees.ts:95,-b <branch>after--); flag-injection (--leading) and traversal already rejected; the worktree dir is containment-checked (computeWorktreeDirM2,:146). - Destructive keep-winner — a single explicit confirm before deleting N−1 worktrees;
removeWorktree's realpath-must-match-a-registered-worktree spine (worktrees.ts:313-323) means only genuine linked worktrees can be deleted, never an arbitrary path, and nevermain. Errors render viatextContent(SEC-L3/H6), neverinnerHTML. - DoS bounds — N capped by
maxFanoutLanes/grid-6/maxSessions; each lane is one bounded PTY; grouping endpoint does zerogit/FS work (pure string grouping over the in-memory list). - Audit — each
createWorktree/removeWorktreealready logs viasanitizeForLog(server.ts:923,952); no new logging path, prompt bytes are never logged.
Effort & dependencies
- Rough effort: L (~3–4 dev-days). Backend is genuinely thin: config field + one pure grouping module + one read-only route (~0.5 d incl. tests). FE carries the weight:
public/fanout.tspure helpers (~0.5 d),renderFanoutForm+onFanoutwiring (~0.5 d),launchFanout/keepFanoutWinner/lane-group model + persistence + the 🏆 cell button (~1.5 d), tests (~0.75 d). - Depends on (all shipped): W2
initialInput(terminal-session.ts:127,374) +openProject/addEntry(tabs.ts:836,704); W4createWorktree(worktrees.ts:224) +removeWorktree(:301) +removeWorktreeReq/confirmAndRemoveWorktree(projects.ts:299,349); the split-grid board (grid-layout.ts,tabs.tsapplyLayout/renderInlineApprove/renderCell). No node-pty/protocol change, no new WS frame, no DB/migration, no new npm dep. - Coordination: the
src/types.tsedit (SessionGroup,FanoutLaunchOpts,UiConfig.maxFanoutLanes,Config.maxFanoutLanes) is the only cross-cutting change — freeze it first and update every all-fieldsCFGtest fixture in the same commit (it touchesConfig). - Optional follow-ups (explicitly out of v1 scope): a thin
merge(repoPath, branch, opts)insrc/http/git-ops.ts(sibling ofcommit:218/push:312, reusingclassifyGitError:81) behindPOST /projects/git/mergefor one-click "land the winner"; server-sidegit --git-common-dirgrouping for exact (non-heuristic) clustering; auto-rollback of partially-created worktrees on launch failure. Each is additive and can ship after the board proves out.