lib/helpers.js
Rama main · réplica de lectura del repositorio original.
// 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());
}