Ctrl K
ComunidadGuías…

lib/helpers.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Pure helpers extracted from server.js for testability
import stripAnsi from 'strip-ansi';
import { basename, resolve } from 'path';
import { createHash } from 'crypto';
import { homedir } from 'os';

// --- Constants ---

export const CODENAMES = [
  'Shadow', 'Phantom', 'Viper', 'Cipher', 'Raven', 'Onyx', 'Echo',
  'Spectre', 'Falcon', 'Ghost', 'Dagger', 'Mirage', 'Cobra', 'Apex', 'Ember'
];

export const COCKTAILS = [
  'vesper', 'martini', 'gimlet', 'negroni', 'sidecar', 'daiquiri',
  'manhattan', 'mojito', 'paloma', 'sazerac', 'aviation', 'bellini',
  'spritz', 'collins', 'julep', 'highball', 'rickey', 'fizz'
];

export const AGENT_COLORS = [
  '#4a9eff', '#ff6b6b', '#ffd43b', '#51cf66', '#cc5de8',
  '#ff922b', '#20c997', '#f06595', '#5c7cfa', '#ffe066'
];

export const PROMPT_PATTERNS = [
  /^❯\s*$/,
  /^>\s*$/,
  /\$\s*$/,
  /^claude[->❯]\s*$/i,
  /^\s*\?\s*$/,
];

export const MESSAGE_PATTERNS = [
  // Claude Code positions each word with cursor moves, so after stripping the
  // words run together ("No,andtellClaudewhattododifferently"): \s* between
  // words wherever a phrase can be Claude's.
  /Do\s*you\s*want\s*to\s*(proceed|allow|make|run|create|fetch)/i,
  /\(y\/n\)/i,
  /\[Y\/n\]/i,
  // Bounded middles, not `.+`: these run over the last synchronized frame
  // too (up to 2000 chars of agent-controlled text), and an unbounded gap
  // between two literals backtracks quadratically. No dialog puts 200 chars
  // between "Allow" and "to", or between "?" and "(Y/n)".
  /Allow [^\n]{1,200} to (read|edit|write|execute)/i,
  /Press Enter to continue/i,
  // The dialogs each CLI opens, by their own wording, and the answers they
  // offer. Word-bounded and phrase-shaped on purpose: this used to be a bare
  // /approve|deny|allow|reject/, which also matched ordinary prose — Codex's
  // permission picker says "choose what Codex is allowed to do" and "Ask for
  // approval", and every line of an agent's own summary is one "allowed"
  // away from a false "needs you" bubble. (Wording verified against
  // codex-cli 0.153.4 and Claude Code 2.1.273; the Claude dialogs are on
  // file in test/fixtures/claude-permission-dialogs.js.)
  /\bWould you like to (run|make|grant|send)\b/i,      // Codex: command, edits, permissions, terminal input
  /\bApprove app tool call\?/i,
  /\bApply Changes\?/i,
  /\bEnable full access\?/i,
  /\bUpdate Model Permissions\b/,                      // the /permissions picker's title — Title Case, unlike prose about it
  // Codex keeps its spaces, so its answers are word-bounded on both sides.
  /\bYes, (proceed|just this once|continue anyway|grant)\b/i,
  /\bNo, (continue without (running|permissions)|and block this host)\b/i,
  // The two answers both CLIs offer, space-tolerant for Claude and with no
  // trailing \b: with the spaces gone the phrase runs straight into the next
  // word ("...whattododifferently").
  /\bYes,\s*and\s*(don['’]t|do\s*not)\s*ask\s*again/i,
  /\bNo,\s*and\s*tell\s*(Codex|Claude)\s*what\s*to\s*do/i,
  // Claude Code 2.1's permission dialog: its answers, and its footer, since
  // four answers push the question itself out of the five-line window.
  /\bYes,\s*and\s*(always\s*allow|switch\s*to)/i,
  /Esc\s*to\s*cancel\s*·\s*Tab\s*to\s*amend/i,
  /\b(Allow for this session|Always allow|Allow and don['’]t ask)\b/i,
  // Gemini CLI and aider, the other TUIs a tab may run, by their own answers.
  /\bAllow execution\?/i,
  /\bYes, allow (once|always)\b/i,
  /\(Y\)es\/\(N\)o/i,
  // Claude Code's TUI dialogs. Two things make these need their own patterns:
  //
  //  1. The TUI positions each word with cursor moves rather than spaces, so
  //     after stripAnsiComplete the text arrives run together —
  //     "Yes,Itrustthisfolder". Hence \s* between every word, not a literal
  //     space. (Verified against real captured PTY output.)
  //  2. Without them a dialog reads as plain WAITING, which for a TUI agent is
  //     indistinguishable from "idle at the prompt". The workspace-trust dialog
  //     in particular greets EVERY agent spawned into a fresh worktree, so a
  //     job-board agent would otherwise sit there looking like it was working.
  /Yes,?\s*I\s*trust\s*this\s*folder/i,
  // Codex's own trust dialog, as 0.156 words it ("› 1. Trust and continue
  // 2. Quit"). Unmatched, it read as WAITING, and an agent message typed into
  // it answered it: the Enter picked option 1 and saved the folder as trusted.
  // (0.155 words it "› 1. Yes, continue  2. No, quit", which "Press Enter to
  // continue" above already catches.)
  /\bTrust\s*and\s*continue\b/i,
  // Codex's hook-trust prompt at startup, when ~/.codex/hooks.json has new or
  // changed hooks ("Hooks need review … Press enter to confirm or esc to go
  // back"), and the hooks browser its option 1 opens ("Press t to trust all").
  // Both read as WAITING, and an agent message's Enter picked "Review hooks".
  /\bHooks\s*need\s*review\b/i,
  /Press\s*enter\s*to\s*confirm\s*or\s*esc/i,
  /Press\s*t\s*to\s*trust\s*all/i,
  // "select" as well as "confirm": the multiple-choice prompt's footer reads
  // "Enter to select ... Esc to cancel", and without it an agent blocking on a
  // question reads as plain WAITING -- indistinguishable from resting at the
  // prompt, which is what now sends it wandering off mid-question.
  //
  // The middle is a bounded [^\n]{0,120} rather than the old `\s*.?\s*`: that
  // allowed exactly ONE character between the verb and "Esc", so the real
  // footer ("Enter to select · up/down to navigate · n to add notes · Esc to
  // cancel") never matched, and its three mutually ambiguous quantifiers
  // backtracked quadratically on a line an agent controls (32k spaces = ~800ms
  // per scan, run against every PTY chunk).
  /Enter\s*to\s*(?:confirm|select)\b[^\n]{0,120}Esc\s*to\s*cancel/i,
];

export const STATE_TIMEOUT_MS = 3000;

// --- Factory: Codename Pool ---

export function createCodenamePool(names = CODENAMES) {
  const used = new Set();
  // Names no agent may ever take (Billion's): held for good, whatever else
  // is recycled — an old orphan that happens to carry one included.
  const reserved = new Set();
  return {
    reserve(name) {
      reserved.add(name);
      used.add(name);
    },
    pick() {
      const available = names.filter(n => !used.has(n));
      if (available.length > 0) {
        const name = available[Math.floor(Math.random() * available.length)];
        used.add(name);
        return name;
      }
      for (const base of names) {
        for (let i = 2; i <= 99; i++) {
          const name = `${base}-${i}`;
          if (!used.has(name)) {
            used.add(name);
            return name;
          }
        }
      }
      return `agent-${Date.now()}`;
    },
    recycle(name) {
      if (reserved.has(name)) return;
      used.delete(name);
    },
    has(name) {
      return used.has(name);
    },
    addUsed(name) {
      used.add(name);
    },
    get usedCount() {
      return used.size;
    },
  };
}

// --- Factory: Cocktail Pool ---

// Names to TRY, in order. Deliberately not a ledger of what is in use.
//
// Git already knows which branches exist — `worktree add -b` answers definitively
// and atomically, including for branches made outside this app, on another
// machine, or a second ago. So the caller walks these candidates and stops at the
// first one git accepts. Nothing here has to stay in sync with anything.
//
// `reject` is a speed hint, never a source of truth: a name git turned down goes
// to the back of the queue so the next spawn tries it last, not never. A branch
// can be deleted at any time, and a stale hint must not cost us a usable name.
export function createCocktailPool(names = COCKTAILS) {
  const rejectedByRepo = new Map();

  // Round 1 is the bare cocktail; later rounds prefix it — 2nd-vesper, 3rd-vesper.
  // These become real branch names, so 21st beats 21th.
  const ordinal = (round) => {
    const suffix = round % 100 >= 11 && round % 100 <= 13
      ? 'th'
      : ({ 1: 'st', 2: 'nd', 3: 'rd' }[round % 10] || 'th');
    return `${round}${suffix}`;
  };

  const shuffled = (list) => {
    const out = [...list];
    for (let i = out.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [out[i], out[j]] = [out[j], out[i]];
    }
    return out;
  };

  return {
    // Random order within a round so two concurrent spawns rarely open with the
    // same name; if they do, the loser just takes the next candidate.
    *candidates(repoPath, maxRounds = 99) {
      // Bind the live Set, creating it if absent. `?? new Set()` would hand back a
      // detached copy on a repo's first spawn, so rejections made mid-walk would
      // land in a different object than the one being read.
      if (!rejectedByRepo.has(repoPath)) rejectedByRepo.set(repoPath, new Set());
      const rejected = rejectedByRepo.get(repoPath);
      for (let round = 1; round <= maxRounds; round++) {
        const prefix = round === 1 ? '' : `${ordinal(round)}-`;
        const pool = shuffled(names).map(n => `${prefix}${n}`);
        // Partition BEFORE yielding either half. The caller rejects each name as
        // it fails, so a lazily-evaluated second filter would re-collect names the
        // first half already yielded and hand them back inside the same round —
        // burning the attempt budget on names we just proved were taken.
        const fresh = pool.filter(n => !rejected.has(n));
        const stale = pool.filter(n => rejected.has(n));
        yield* fresh;   // names with no strike against them
        yield* stale;   // then the hints, in case a branch was deleted
      }
    },
    reject(repoPath, name) {
      if (!rejectedByRepo.has(repoPath)) rejectedByRepo.set(repoPath, new Set());
      rejectedByRepo.get(repoPath).add(name);
    },
  };
}

// --- Factory: Color Cycler ---

export function createColorCycler(colors = AGENT_COLORS) {
  let index = 0;
  return {
    next() {
      const color = colors[index % colors.length];
      index++;
      return color;
    },
  };
}

// --- Pure Functions ---

export function stripAnsiComplete(str) {
  let result = stripAnsi(str);
  result = result.replace(/\x1b\].*?(?:\x07|\x1b\\)/g, '');
  result = result.replace(/\x1bP.*?\x1b\\/g, '');
  result = result.replace(/\x1b[=>()]/g, '');
  return result;
}

// Output that says something happened, as opposed to a spinner tick, a rule
// of box-drawing, or the remnant of a half-stripped escape (the ' q' left by
// ESC[0 SP q, the cursor-shape sequence). One test for the freshness clock
// and for whether a synchronized frame is a repaint worth keeping.
const TRIVIAL_RE = /^[\s.·•⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷─━▏▎▍▌▋▊▉█░▒▓⬡◐◑◒◓|\\\/\-*>]+$/;
const ESCAPE_REMNANT_RE = /^[\d;]*[a-zA-Z]$/;
export function isRealOutput(text) {
  return text.length > 3 && !TRIVIAL_RE.test(text) && !ESCAPE_REMNANT_RE.test(text);
}

// Synchronized output (DEC mode 2026): a TUI brackets each repaint in
// ?2026h ... ?2026l so the terminal shows it whole. Codex draws its bottom
// pane — composer, status line, and every dialog and picker — that way, so
// the last complete frame is the pane as it stands: the picker while it is
// open, the bare prompt once it is answered. detectState reads it in place of
// the five-line window, which for Codex holds whatever last got a newline and
// so keeps an answered dialog's text long after the screen let it go.
//
// A pty read is not a frame, nor even a whole marker: an open frame's body is
// carried between reads, and so are the last few bytes before a frame, so a
// marker cut by a read boundary is still found — a lost end marker would
// otherwise glue the next repaint onto a stale one, head first, and the
// answered dialog would be back. A begin marker inside an open frame restarts
// it: that is a new repaint. A frame whose text is nothing but cursor-shape
// remnants and whitespace — Codex blinks those while idle — is not a repaint
// of the pane and leaves the last real frame in place. The raw bound keeps
// the head; the text kept for matching is the head AND the tail, since a
// dialog states its question first and lists its answers last, and the agent
// controls how long whatever sits between them runs.
//
// Not every frame is the whole pane. Codex repaints just the option rows
// when the user arrows through a picker, re-inserts history boxes frame by
// frame after a resize, and prints each history line ("• Permissions
// updated …") as a frame of its own. A frame that carries the composer or
// the status row is the pane and replaces what was held; any other frame is
// a partial repaint and is merged onto it. The caller says which is which
// (`pane`), since the status row names the working directory. What that
// rule still cannot know is the screen itself — a terminal screen model is
// the full answer; see TODOS.md.
//
// A frame left open for more than a second is abandoned: no repaint takes
// that long, and a writer that died mid-frame must not swallow the rest of
// the session into a body that never closes.
const SYNC_BEGIN = '\x1b[?2026h';
const SYNC_END = '\x1b[?2026l';
const MARKER_LEN = SYNC_BEGIN.length;
export const FRAME_RAW_MAX = 64 * 1024;
export const FRAME_TEXT_MAX = 2000;
// Returns what this read contributed OUTSIDE any frame — the line stream the
// handler should reassemble — and how many bytes of the previous read turned
// out to be the head of a marker straddling the two, so the handler can
// take them back out of its carry. A frame's bytes are a repaint, never line
// text: left in the line stream, a cancelled picker's text would still be
// "the last line" for as long as the next 2000 bytes took to arrive.
export const FRAME_OPEN_MAX_MS = 1000;
export function trackSyncFrames(session, data, now = Date.now(), { pane = () => true } = {}) {
  if (typeof data !== 'string' || data.length === 0) return { outside: '', straddle: 0 };
  if (typeof session.frameOpen === 'string' && now - (session.frameOpenedAt || now) > FRAME_OPEN_MAX_MS) {
    session.frameOpen = null;   // abandoned; what it held is neither pane nor line text
    session.frameTail = '';
  }
  let open = typeof session.frameOpen === 'string';
  const prefix = open ? session.frameOpen : (session.frameTail || '');
  let buf = prefix + data;
  // Where buf[0] sits in `data` (negative while the prefix is still in front).
  let pos = -prefix.length;
  let at = open ? Math.max(0, prefix.length - (MARKER_LEN - 1)) : 0;
  let outside = '';
  let straddle = 0;
  for (;;) {
    const begin = buf.indexOf(SYNC_BEGIN, at);
    const end = open ? buf.indexOf(SYNC_END, at) : -1;
    if (open && end !== -1 && (begin === -1 || end < begin)) {
      const text = stripAnsiComplete(buf.slice(0, Math.min(end, FRAME_RAW_MAX))).replace(/\s+/g, ' ').trim();
      if (isRealOutput(text)) {
        const kept = !pane(text) && session.lastFrame ? `${session.lastFrame} ${text}` : text;
        session.lastFrame = kept.length > 2 * FRAME_TEXT_MAX
          ? `${kept.slice(0, FRAME_TEXT_MAX)} … ${kept.slice(-FRAME_TEXT_MAX)}`
          : kept;
        session.lastFrameAt = now;
      }
      buf = buf.slice(end + MARKER_LEN);
      pos += end + MARKER_LEN;
      open = false;
      at = 0;
      continue;
    }
    if (begin !== -1) {
      if (!open) {
        // Text before the marker is line text — the part of it in THIS read.
        outside += buf.slice(Math.max(0, -pos), begin);
        if (pos + begin < 0) straddle = -(pos + begin);   // the marker began in the previous read's tail
      }
      buf = buf.slice(begin + MARKER_LEN);
      pos += begin + MARKER_LEN;
      open = true;
      session.frameOpenedAt = now;
      at = 0;
      continue;
    }
    break;
  }
  if (open) {
    // Text past the cap is dropped, markers are not: the body keeps its last
    // few bytes past the cap, so a marker straddling the next read is still
    // found and the frame still closes.
    session.frameOpen = buf.length > FRAME_RAW_MAX
      ? buf.slice(0, FRAME_RAW_MAX - (MARKER_LEN - 1)) + buf.slice(-(MARKER_LEN - 1))
      : buf;
    session.frameTail = '';
  } else {
    outside += buf.slice(Math.max(0, -pos));
    session.frameOpen = null;
    session.frameTail = buf.slice(-(MARKER_LEN - 1));
  }
  return { outside, straddle };
}

export function detectState(session, { now, stateTimeoutMs, promptPatterns, messagePatterns } = {}) {
  const timestamp = now ?? Date.now();
  const timeout = stateTimeoutMs ?? STATE_TIMEOUT_MS;
  const prompts = promptPatterns ?? PROMPT_PATTERNS;
  const messages = messagePatterns ?? MESSAGE_PATTERNS;

  const timeSinceOutput = timestamp - session.lastOutputAt;
  if (session.exited) return 'DISCONNECTED';
  if (timeSinceOutput < timeout) return 'WORKING';
  const lastLine = session.lastStrippedLine || '';
  for (const pattern of messages) {
    if (pattern.test(lastLine)) return 'MESSAGE';
  }
  // A TUI that repaints inside synchronized-output frames (Codex does; Claude
  // Code does not) tells us what is on screen NOW: its last frame. That is the
  // dialog when one is open, and the bare prompt once it is answered. The
  // five-line window below is for everything else — and is exactly what went
  // stale for Codex, which redraws with cursor moves rather than newlines, so
  // an answered picker's text sat in the window until five real lines pushed
  // it out, with the bubble showing the whole time.
  //
  // The newer source speaks. A frame older than the last whole LINE printed
  // outside one is a program that stopped painting in frames — a shell tab
  // that ran one such tool and then launched Claude Code — and the window is
  // current again. Whole lines, not bytes: while a dialog waits, Codex writes
  // its window title outside any frame every second, which strips to nothing
  // whole but to a remnant when a read boundary falls inside it, and a
  // remnant must not outrank the dialog. (A tie goes to the frame.)
  const frame = typeof session.lastFrame === 'string' && (session.lastFrameAt || 0) >= (session.lastLineAt || 0)
    ? session.lastFrame : '';
  if (frame) {
    for (const pattern of messages) {
      if (pattern.test(frame)) return 'MESSAGE';
    }
  } else {
    for (const line of session.recentStrippedLines) {
      for (const pattern of messages) {
        if (pattern.test(line)) return 'MESSAGE';
      }
    }
  }
  for (const pattern of prompts) {
    if (pattern.test(lastLine)) return 'WAITING';
  }
  if (session.isTUI) return 'WAITING';
  return 'IDLE';
}

// A leading ~/ as the home directory, the way a shell would read it.
export function expandHome(path, home = homedir()) {
  const raw = String(path ?? '').trim();
  return raw.startsWith('~/') ? resolve(home, raw.slice(2)) : raw;
}

// Split a command string into { file, args } with shell-like quoting so
// commands with spaces survive (e.g. bash -lc "echo hi; ls"). Handles single
// quotes, double quotes, and backslash escapes. Naive split(/\s+/) broke any
// command that needed a quoted argument.
export function parseCommand(command) {
  const tokens = [];
  let current = '';
  let hasToken = false;
  let inSingle = false;
  let inDouble = false;
  for (let i = 0; i < command.length; i++) {
    const c = command[i];
    if (inSingle) {
      if (c === "'") inSingle = false;
      else current += c;
      hasToken = true;
    } else if (inDouble) {
      if (c === '"') inDouble = false;
      else if (c === '\\' && (command[i + 1] === '"' || command[i + 1] === '\\')) current += command[++i];
      else current += c;
      hasToken = true;
    } else if (c === "'") {
      inSingle = true; hasToken = true;
    } else if (c === '"') {
      inDouble = true; hasToken = true;
    } else if (c === '\\' && i + 1 < command.length) {
      current += command[++i]; hasToken = true;
    } else if (/\s/.test(c)) {
      if (hasToken) { tokens.push(current); current = ''; hasToken = false; }
    } else {
      current += c; hasToken = true;
    }
  }
  if (hasToken) tokens.push(current);
  return { file: tokens[0] || '', args: tokens.slice(1) };
}

// The environment every pty gets. A server started by launchd, systemd or a
// bare ssh command has no locale, so its terminals ran in POSIX: readline,
// vim and tmux mangled UTF-8, and ssh (which forwards LANG) carried the same
// blindness to the remote host. A UTF-8 LANG fills in only when none is set.
//
// It is also where Agent 007's own settings are taken out. The server's
// environment holds everything ~/.agent-007/.env and ./.env set, and every
// agent it starts used to inherit all of it: any worker, and anything a worker
// ran, could read the Telegram bot token, and a Claude Code Telegram plugin in
// a worker found it and polled Billion's bot. Every .env.example setting is the
// server's alone (test/helpers.test.js checks that each one is listed here):
// secrets (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID), where the server keeps its
// files (AGENT007_*, BILLION_DIR), and switches no agent reads — PORT and HOST
// included, which a worker's own dev server would otherwise pick up. Whatever
// else the owner's shell set (HOME, PATH, CODEX_HOME, CLAUDE_CONFIG_DIR,
// proxies, API keys the CLIs use) passes through untouched.
export const SERVER_ONLY_ENV = [
  'PORT', 'HOST', 'ALLOWED_ORIGINS', 'PUBLIC_URL',
  'CLAUDE_PERMISSION_MODE', 'CODEX_PERMISSION_MODE', 'AGENT_MESSAGING', 'RESPAWN_BOARD_WORKERS', 'MEMORY_BACK_PRESSURE', 'MAC_MINI_GUARD_FILE',
  'BILLION', 'BILLION_DIR', 'BILLION_AGENT', 'BILLION_AUTO_SWITCH',
  'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID', 'TELEGRAM_VOICE',
  'SAY_VOICE', 'SAY_RATE', 'WHISPER_MODEL', 'WHISPER_CPP_BIN',
  'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE', 'ELEVENLABS_MODEL',
  'TRUST_BOARD_WORKTREES', 'SKILL_FAMILIES',
];
// AGENT007_CONFIG_DIR, _USERS_PATH (the login users file), _WORKTREE_DIR, _MCP_DIR.
const SERVER_ONLY_PREFIX = 'AGENT007_';

export function ptyEnv(env) {
  const hasLocale = env.LC_ALL || env.LC_CTYPE || env.LANG;
  const out = { ...env, TERM: 'xterm-256color', ...(hasLocale ? {} : { LANG: 'en_US.UTF-8' }) };
  for (const key of Object.keys(out)) {
    const k = key.toUpperCase();   // Windows names are case-insensitive
    if (SERVER_ONLY_ENV.includes(k) || k.startsWith(SERVER_ONLY_PREFIX)) delete out[key];
  }
  return out;
}

export function parseGitStatus(output) {
  const files = [];
  for (const line of output.split('\n')) {
    if (!line.trim()) continue;
    const xy = line.slice(0, 2);
    let path = line.slice(3);
    if (xy === '!!') continue;
    let status;
    if (xy === '??') {
      status = '?';
    } else if (xy[0] === 'R' || xy[1] === 'R') {
      status = 'R';
      const arrow = path.indexOf(' -> ');
      if (arrow !== -1) path = path.slice(arrow + 4);
    } else if (xy[1] !== ' ') {
      status = xy[1];
    } else {
      status = xy[0];
    }
    files.push({ path, status });
  }
  return files;
}

export function buildFileTree(files, repoName) {
  const root = { name: repoName, children: [], type: 'dir' };
  for (const { path, status } of files) {
    const parts = path.split('/');
    let current = root;
    for (let i = 0; i < parts.length; i++) {
      const name = parts[i];
      if (i === parts.length - 1) {
        current.children.push({ name, status, type: 'file', path });
      } else {
        let dir = current.children.find(c => c.name === name && c.type === 'dir');
        if (!dir) {
          dir = { name, children: [], type: 'dir' };
          current.children.push(dir);
        }
        current = dir;
      }
    }
  }
  return root;
}

export function createRingBuffer(maxSize) {
  const buffer = [];
  return {
    push(item) {
      buffer.push(item);
      if (buffer.length > maxSize) buffer.shift();
    },
    getAll() { return [...buffer]; },
    get length() { return buffer.length; },
  };
}

export function repoDirName(repoPath) {
  const name = basename(repoPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
  const hash = createHash('md5').update(repoPath).digest('hex').slice(0, 4);
  return `${name}-${hash}`;
}

// One sanitiser for every file name a client hands the server (terminal
// uploads, job attachments). Only [A-Za-z0-9._-] survive, so no separator
// does, and a Windows device name (CON, NUL, COM1...) is prefixed so
// writeFileSync cannot open a device instead of a file.
export function safeFilename(name) {
  const clean = String(name || '').replace(/[^a-zA-Z0-9._-]/g, '_');
  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(clean) ? `_${clean}` : clean;
}

// An on-by-default switch from the environment: off only for 0/false/off/no.
export function envSwitchOn(value) {
  return !/^(0|false|off|no)$/i.test(String(value ?? '').trim());
}