/** * 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 ` 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' }