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.
75 lines
3.0 KiB
TypeScript
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'
|
|
}
|