Files
web-terminal/public/fanout.ts
Yaojia Wang 9683a16f4f 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.
2026-07-13 05:09:19 +02:00

75 lines
3.0 KiB
TypeScript

/**
* public/fanout.ts (W5 fan-out board) — PURE launch-command + branch-slug helpers.
*
* No DOM, no fetch, no side effects — every export is unit-testable in isolation.
* The load-bearing invariant is the prompt shell-quoting (shellSingleQuote): the
* prompt is typed as raw bytes into a lane's shell BEFORE `claude` parses it, so
* it must be wrapped as a single literal argv element — no `$(...)`, backtick,
* `;`, `&&`, or redirection may execute. NEVER String-concat the prompt into the
* command unquoted (see the plan's Security section).
*/
import type { PermissionMode } from '../src/types.js'
/** Max prompt length typed into a lane (bounded DoS + keeps the command sane). */
export const FANOUT_PROMPT_MAX = 4000
/** Default/max fan-out lanes (mirrors the server MAX_FANOUT_LANES default and the
* grid-6 board capacity). The FE stepper max and the launch clamp both use it. */
export const FANOUT_MAX_LANES = 6
/**
* POSIX single-quote a string so the shell passes it verbatim as ONE argv element.
* Wrap in single quotes and replace every embedded `'` with the classic
* `'\''` sequence (close-quote, escaped-quote, re-open-quote). Pure.
*/
export function shellSingleQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`
}
/**
* Normalize a prompt for typing into a PTY: collapse every line break (`\r`, `\n`,
* `\r\n`) to a single space — a bare newline keystroke would submit a partial line
* before `claude` starts — then trim and cap at FANOUT_PROMPT_MAX. Pure.
*/
export function sanitizePrompt(s: string): string {
return s.replace(/[\r\n]+/g, ' ').trim().slice(0, FANOUT_PROMPT_MAX)
}
/**
* Build the `claude` launch command for one lane. Single-quotes the sanitized
* prompt (injection-safe), prefixes `--permission-mode <m>` for non-default modes,
* and ends with `\r` so the shell auto-executes it. 'auto' is downgraded to
* 'default' when the server forbids it (SEC-M5 parity with resolveMode). Pure.
*/
export function buildFanoutCmd(
prompt: string,
mode: PermissionMode,
allowAutoMode: boolean,
): string {
const effective: PermissionMode = mode === 'auto' && !allowAutoMode ? 'default' : mode
const flag = effective === 'default' ? '' : `--permission-mode ${effective} `
return `claude ${flag}${shellSingleQuote(sanitizePrompt(prompt))}\r`
}
/** Lane branch name: `${base}-lane-${i}` (i is 1-based). Pure. */
export function laneBranch(base: string, i: number): string {
return `${base}-lane-${i}`
}
/**
* Derive a safe git-branch-base slug from a free-text prompt: lowercase, non-
* alphanumeric runs → single `-`, strip leading/trailing dashes, cap at 40 chars.
* Falls back to 'fanout' when nothing survives. The result is a valid branch base
* (passes validateBranchNameClient). Pure.
*/
export function slugify(prompt: string): string {
const slug = prompt
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'fanout'
}