feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
This commit is contained in:
@@ -45,6 +45,7 @@ import { buildDigest, clampSince } from './http/digest.js'
|
||||
import { getPrStatus } from './http/gh.js'
|
||||
import { parseStatusLine } from './http/statusline.js'
|
||||
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
|
||||
import { groupSessionsByRepo } from './http/session-groups.js'
|
||||
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
@@ -333,6 +334,14 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.json(manager.list())
|
||||
})
|
||||
|
||||
// ── W5 fan-out board: running sessions clustered by repo (read-only) ──────
|
||||
// Pure string grouping over manager.list() (no git/FS work); same threat model
|
||||
// as /live-sessions and /digest → no Origin guard. Registered BEFORE the
|
||||
// /live-sessions/:id routes so "grouped" is never captured as an :id.
|
||||
app.get('/live-sessions/grouped', (_req, res) => {
|
||||
res.json(groupSessionsByRepo(manager.list()))
|
||||
})
|
||||
|
||||
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
|
||||
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
|
||||
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
|
||||
@@ -1102,6 +1111,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// W3(b): expose the cost budget only when set (>0) so the FE can derive
|
||||
// cost-overage warn styling; a non-secret number, safe over /config/ui.
|
||||
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
|
||||
// W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES.
|
||||
maxFanoutLanes: cfg.maxFanoutLanes,
|
||||
}
|
||||
res.json(uiConfig)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user