server.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Agent 007 — Entry point + orchestrator functions
//
// Architecture:
// server.js Entry point, createSession/killSession orchestrators
// server/state.js Shared mutable state (sessions, orphans, pools, config)
// server/config.js Config persistence (load, save, crash recovery)
// server/git.js Git operations (worktree, file tree, diff)
// server/pty.js PTY lifecycle (spawn, handlers, state detection)
// server/jobs.js Job board (persistence, dispatcher loop, PR watching)
// server/ws.js WebSocket (message routing, broadcast, origin check)
// server/http.js HTTP routes (/api/browse, /api/jobs, /mcp, origin + auth)
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { fileURLToPath, pathToFileURL } from 'url';
import { isDirectRun } from './server/direct-run.js';
import { dirname, join, basename } from 'path';
import { mkdirSync, readFileSync } from 'fs';
import { randomBytes } from 'crypto';
import {
PORT, HOST, LOOPBACK_HOSTS, WILDCARD_BIND_HOSTS, WORKTREE_DIR, PUBLIC_URL, sessions,
codenamePool, colorCycler, nextSessionId,
} from './server/state.js';
import { loadConfig, recoverCrashedSessions, saveActiveSession, removeActiveSession, syncOrphansToConfig, sessionAgent, sessionPermissionFlags, sessionOrigin } from './server/config.js';
import { addRepo, createWorktree, removeWorktree, pruneWorktrees, discardWorktree, scanForOrphanedWorktrees, startTreeScanLoop, detectConflicts, deleteBranch } from './server/git.js';
import { createSessionFromConfig, killSessionProcesses, blockSpawns } from './server/pty.js';
import { setupWebSocket, broadcast, broadcastToBrowsers, sessionPayload, broadcastOrphansList, verifyClient, respawnAgent, respawnBoardWorkers, mayAnswerOwner } from './server/ws.js';
import { setupRoutes, checkOrigin } from './server/http.js';
import { proxySeen } from './server/proxy.js';
import { controlRoutes, writeServerFile, busyWorkers, RESTART_EXIT, VERSION } from './server/control.js';
import { startDispatcher, stopDispatcher, boardSettings, boardGate, releasePushedOrphans, requestDispatch, ghEnvForRepo, retireSpentSchedules, convertOnceSchedules } from './server/jobs.js';
import { orphans, config, CONFIG_DIR } from './server/state.js';
import { toolsFor } from './server/mcp.js';
import { sweepMcpConfigs, startCodexHookLookup } from './server/agent-mcp.js';
import { withDefaultPermission, envPermissionMode, PERMISSION_MODES, ENV_PERMISSION_MODE, sessionAgentFromCommand, deriveJobStatus, isCodexSessionId } from './lib/jobs.js';
import { BILLION_NAME, billionEnabled, billionRuns, billionDir, ensureBillionRepo, refreshCharter, suggestProjectsDir, billionCommand, noAgentCommand, changedBoardTools, saveBoardTools, charterChanges, writeAgentsMd, billionAgent, saveBillionAgent, billionAgentWarning, noAgentNotice, notLoggedInNotice, setBillionNotice, switchBillion as switchBillionSteps, liveBillion, withBillionStopped } from './server/billion.js';
import { writeHandover } from './server/billion-handover.js';
import { wakeTick, billionBusy, WAKE_TICK_MS } from './server/billion-wake.js';
import { limitTick, cliReady, matchLimit, SETTLE_MS } from './server/billion-limit.js';
import { migrate as migrateAccount, rollback as rollbackAccount, retire as retireAccount, setup as setupAccount, setArmed as armAccount, isArmed as accountArmed, publicState as accountState, canMigrate, checkSwitch, recheck as recheckAccount, BUSY_ERROR } from './server/account-migration.js';
import { publicRotationState, rotationState, addRotationAccount, configureRotation, rotateAccount, recoverRotation, selectedCount, otherCli, ROTATION_CLIS } from './server/account-rotation.js';
import { assertClaudeProcessesManaged } from './server/claude-processes.js';
import { withClaudeSessionsStopped } from './server/claude-rotation-sessions.js';
import { stopCodexDaemon } from './server/codex-login.js';
import { takeMessages, restoreMessages, dropMessages, screenTail, sendNotice } from './server/messages.js';
import { readMap as readFamilyMap, reportUngrouped } from './server/skill-families.js';
import { reportDuplicates } from './server/skill-duplicates.js';
import { autoSync, reportStoreConflicts, reportRepoSkills } from './server/skill-store.js';
import { allJobs, autoContinueWorkers } from './server/jobs.js';
import { commandExists, missingCommandMessage } from './server/command-path.js';
import { parseCommand, envSwitchOn } from './lib/helpers.js';
import { refreshListing } from './server/skill-listing.js';
import { hasClaudeTranscript, codexSessionIdFor } from './server/agent-transcripts.js';
import { autoTrusts, trustClaudeFolder } from './server/claude-trust.js';
import { startTelegram, stopTelegram, notifyOwner, tellOwner, roundTick, requeueOwnerMessages } from './server/owner.js';
import { comingRound } from './server/rounds.js';
import { setStatusFacts, publishStatus, noteScreenSteps, STEP_TICK_MS } from './server/billion-status.js';
import { startModelRefresh, modelsReady, availableModels, onModelsChange } from './server/models.js';
import { agentAccounts, setOnScan } from './server/agent-accounts.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
// A reverse proxy on this machine (docs/REMOTE.md) is believed about the
// browser's scheme and address (X-Forwarded-Proto/For); nobody else is.
app.set('trust proxy', 'loopback');
const server = createServer(app);
// Compressed: terminal redraws and JSON shrink several-fold, which is what a
// remote browser on a slow link needs. Each socket keeps a deflate context
// (deflate about 100 KB at these window and memory sizes, inflate 32 KB);
// messages under 1 KB go raw.
const wss = new WebSocketServer({
server, verifyClient,
perMessageDeflate: { threshold: 1024, serverMaxWindowBits: 13, zlibDeflateOptions: { level: 6, memLevel: 7 } },
});
// --- HTTP routes ---
// broadcast is injected for the same reason server/jobs.js takes it as an
// argument: http.js must not import ws.js, and a job posted through the MCP
// tool has to repaint every open board the moment it lands.
// killSession is a hoisted declaration below: close_job retires a card's worker.
setupRoutes(app, join(__dirname, 'public'), { broadcast, killSession, respawnAgent });
// `agent007 status` and `restart` (server/control.js); server.json carries
// the token once the server listens.
const controlToken = randomBytes(32).toString('hex');
// The board's worker limit as `agent007 status` and `agent007 doctor` show it.
function dispatchStatus() {
const { maxWorkers, cap, running, level, reasons, reason } = boardGate();
return { maxWorkers, cap, running, level, reasons, reason, maxPerRepo: boardSettings().maxPerRepo, boardRunning: !!boardSettings().running };
}
controlRoutes(app, {
token: controlToken,
checkOrigin,
status: () => ({ pid: process.pid, version: VERSION, port: Number(PORT), service: process.env.AGENT007_SERVICE || null, uptime: Math.round(process.uptime()), sessions: sessions.size, workers: busyWorkers(sessions), dispatch: dispatchStatus(), publicUrl: PUBLIC_URL, proxy: proxySeen() }),
restart: () => gracefulShutdown({ restart: true }),
});
// --- Orchestrators ---
// These span multiple modules (git, pty, config, ws) and stay here.
async function createSession(command, name, repoPath, customBranch, ownerId, meta = {}) {
// A custom name must not already be a live label, an orphan's label, or a
// worktree directory's codename: two holders of one name means the first
// kill frees it while the other still names a directory on disk.
if (name && codenamePool.has(name)) return { error: `An agent named ${name} already exists` };
// A missing CLI, before any worktree is made for it: the board retries a
// failed card every tick. A path is left to the spawn, which knows the cwd.
const { file } = parseCommand(command);
if (!/[\\/]/.test(file) && !commandExists(file)) return { error: missingCommandMessage(file) };
// An agent someone starts gets the .env default mode for its CLI, unless its
// command already says how it asks. The board settles its own workers' mode
// (boardModeFor in server/jobs.js), so their commands are left as built.
if (meta.spawnedBy !== 'board') command = withDefaultPermission(command);
const sessionId = nextSessionId();
const agentName = name || codenamePool.pick();
if (name) codenamePool.addUsed(name);
const color = colorCycler.next();
let worktreePath = null;
let branchName = null;
let repoSlug = null;
let resolvedRepoPath = null;
let cocktail = null;
if (repoPath) {
const result = await addRepo(repoPath, broadcast);
if (result.error) { codenamePool.recycle(agentName); return { error: result.error }; }
resolvedRepoPath = result.path;
repoSlug = result.slug;
// createWorktree picks the name by trying it against git, so it reports back
// which cocktail actually landed. Nothing to reserve or release here.
const wtResult = await createWorktree(resolvedRepoPath, agentName, customBranch, {
suffixOnCollision: !!meta.branchSuffixOnCollision,
startPoint: meta.startPoint || null,
});
if (wtResult.error) {
codenamePool.recycle(agentName);
return { error: wtResult.error };
}
worktreePath = wtResult.worktreePath;
branchName = wtResult.branchName;
cocktail = wtResult.cocktail;
}
// A board worker's worktree is brand new, so Claude Code would stop at its
// workspace-trust dialog until someone clicked (server/claude-trust.js).
// Codex's is skipped by a flag instead (server/pty.js).
const autoTrust = autoTrusts({ spawnedBy: meta.spawnedBy, worktreePath, command });
if (autoTrust && sessionAgentFromCommand(command) === 'claude') trustClaudeFolder(worktreePath);
const result = createSessionFromConfig({
sessionId, name: agentName, color, command,
repoPath: resolvedRepoPath, worktreePath, branchName,
repoSlug, cocktail, ownerId: ownerId || null,
spawnedBy: meta.spawnedBy || 'user', jobId: meta.jobId || null,
approvalsToBillion: !!meta.approvalsToBillion, autoTrust,
// The skill families switched on: a card's (server/jobs.js), or
// engineering for an agent someone starts in a repo.
skills: meta.skills || (resolvedRepoPath ? ['engineering'] : []),
// A board worker gets its repo's GitHub account (server/jobs.js).
ghEnv: meta.spawnedBy === 'board' ? await ghEnvForRepo(resolvedRepoPath) : {},
}, broadcast);
if (result.error) {
codenamePool.recycle(agentName);
// Spawn failed after the worktree was created — remove it and its branch
// so a bad command doesn't leak a worktree + branch on disk.
if (worktreePath && resolvedRepoPath) {
await discardWorktree(resolvedRepoPath, worktreePath);
await deleteBranch(resolvedRepoPath, branchName);
}
return { error: result.error };
}
const session = result.session;
sessions.set(sessionId, session);
saveActiveSession(session, broadcast);
if (worktreePath) {
startTreeScanLoop(session, broadcast);
}
return { session };
}
async function killSession(sessionId, { discardChanges = false, force = false } = {}) {
// Board retirement must complete after rotation, not report a silent success
// while the same worker is about to resume under its original session id.
if (sessions.get(sessionId)?.accountRotating && switching) {
try { await switching; } catch { /* Retirement still owns the stopped session. */ }
}
const session = sessions.get(sessionId);
if (!session) return;
if (session.rotationResume) { session.accountRotating = false; session.rotationResume = false; dropMessages(sessionId); }
session.closing = true; // its exit is a close, with no Restart (pty.js onExit)
// Already exited, so onExit will not say so: its tabs drop their Restart.
if (session.exited) broadcast({ type: 'session-ended', sessionId, reason: 'Closed', spawnedBy: session.spawnedBy, jobId: session.jobId, closed: true });
clearInterval(session.stateCheckInterval);
clearTimeout(session.scanTimer);
killSessionProcesses(session);
removeActiveSession(session.worktreePath, broadcast);
const { orphaned, reason } = await removeWorktree(session, { discardChanges, force });
if (orphaned) {
const orphanId = `orphan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const orphan = {
id: orphanId, name: session.name, repoPath: session.repoPath,
repoSlug: session.repoSlug, worktreePath: session.worktreePath,
branchName: session.branchName, color: session.color,
ownerId: session.ownerId || null,
agent: sessionAgent(session),
permissionFlags: sessionPermissionFlags(session),
origin: sessionOrigin(session),
jobId: session.jobId || null,
approvalsToBillion: !!session.approvalsToBillion,
reason, createdAt: new Date().toISOString(),
};
orphans.set(orphanId, orphan);
syncOrphansToConfig(broadcast);
broadcastOrphansList();
broadcast({ type: 'notification', level: 'info', message: `${session.name} orphaned — worktree kept (${reason} changes)` });
} else {
codenamePool.recycle(session.name);
if (session.worktreePath) codenamePool.recycle(basename(session.worktreePath)); // differs after a rename
}
if (sessions.get(sessionId) === session) {
sessions.delete(sessionId);
// A close, unlike a crash, takes the tab with it in every browser.
broadcast({ type: 'session-removed', sessionId });
}
}
// Billion (server/billion.js): started at boot, and again only when someone
// asks — an agent that crashes in a loop is worse than one that stays stopped.
// Returns the running one if there is one. On whichever CLI billionAgent()
// says; `handover` starts it fresh after a switch, with `carried` the mail the
// last one had waiting.
// Async only for the model lists its prompt names: the first discovery is
// waited for, at most 15 s. Everything after that wait is synchronous, so two
// starts at once still find each other's session.
async function startBillion({ handover = false, carried = null } = {}) {
const modelsIn = await modelsReady();
for (const [id, s] of sessions) {
if (!s.isBillion) continue;
if (s.rotationResume) return { error: `Retry the paused ${CLI_LABEL[s.agent] || 'Claude'} conversations in Settings before starting Billion.` };
if (!s.exited) return { session: s, existing: true };
sessions.delete(id); // a stopped one's tab goes; the new one replaces it
}
const dir = billionDir();
let created;
try {
({ created } = ensureBillionRepo(dir));
} catch (err) {
console.error(`Billion: could not set up ${dir}:`, err.message);
return { error: `Could not set up Billion's folder ${dir}: ${err.message}` };
}
// Best effort: a charter that could not be committed is still the new one on
// disk, and a Billion on last version's charter beats no Billion.
if (!created) {
try {
if (refreshCharter(dir)) console.log(' Billion: charter updated to this version');
} catch (err) {
console.error(`Billion: could not commit the updated charter in ${dir}:`, err.message);
}
}
// Codex's copy of the charter and the owner's rules. Best effort too: a
// Claude Billion never reads it.
try { writeAgentsMd(dir); } catch (err) {
console.error(`Billion: could not write AGENTS.md in ${dir}:`, err.message);
}
const agent = billionAgent();
const hasCli = commandExists(agent, process.env, process.platform, dir);
// With the model lists toolsFor adds, so a list that changed since the last
// start names post_job. Only when the CLI starts, so no start without it
// uses up the notice: it is about Claude Code's resumed conversations, but
// a Codex Billion reads the file too when its prompt had no model list.
// Best effort, like the charter.
let changedTools = [];
if (hasCli) {
try { changedTools = changedBoardTools(BILLION_TOOLS_FILE, billionTools()); } catch (err) {
console.error(`Billion: could not save the board tool definitions to ${BILLION_TOOLS_FILE}:`, err.message);
}
}
// Only when the CLI starts, like the tools, so a start without it does not
// use up the notice. Best effort: a Billion without it still starts.
let charterNotice = '';
if (hasCli) {
try { charterNotice = charterChanges(join(CONFIG_DIR, 'billion-charter.md'), readFileSync(join(dir, 'CHARTER.md'), 'utf8')); } catch (err) {
console.error('Billion: could not compare the charter with the last one:', err.message);
}
}
const command = hasCli ? billionCommand({
agent,
created,
handover,
hasConversation: !created && agent === 'claude' && hasClaudeTranscript(dir),
codexSessionId: !created && agent === 'codex' ? codexSessionIdFor(dir) : null,
dir,
projectsHint: suggestProjectsDir(config.repos.map(r => r.path)),
changedTools,
toolsFile: BILLION_TOOLS_FILE,
charterNotice,
models: modelsIn ? availableModels() : null,
}) : noAgentCommand(agent);
const result = createSessionFromConfig({
sessionId: nextSessionId(), name: BILLION_NAME, color: colorCycler.next(), command,
repoPath: null, worktreePath: null, cwd: dir, isBillion: true, ownerId: null,
// Marketing and the review skills by default; skill-families.json's "billion" changes it.
skills: readFamilyMap().billion,
}, broadcast);
if (result.error) return result;
// Mail waits until Billion calls billion_ready: at the end of its
// introduction, and at the start of every cycle after a restart.
result.session.messagesHeld = true;
restoreMessages(result.session.id, carried);
sessions.set(result.session.id, result.session);
// What the owner sent that the last session never got.
const requeued = requeueOwnerMessages(result.session, { broadcast });
if (requeued) console.log(` Billion: queued ${requeued} owner message(s) the last session never got`);
reportUngrouped(result.session, sendNotice);
reportDuplicates(result.session, sendNotice);
reportStoreConflicts(result.session, sendNotice);
reportRepoSkills(result.session, sendNotice);
// Why it cannot talk yet, for its chat tab. Logged out, the CLI sits at its
// own sign-in and never calls billion_ready, so mail would wait unexplained.
const session = result.session;
const cli = agent === 'codex' ? 'Codex (codex)' : 'Claude Code (claude)';
if (!hasCli) session.notice = noAgentNotice(agent);
else cliReady(agent).then((ok) => {
// Only a definite no: a slow or failed check says nothing either way.
if (ok !== false || session.exited || !session.messagesHeld) return;
console.log(` Billion: ${cli} is not logged in; its tab is at the sign-in`);
setBillionNotice(session, notLoggedInNotice(agent), broadcast);
// Its sign-in goes with it; a stopped Billion's bar says Start instead.
session.pty.onExit(() => setBillionNotice(session, null, broadcast));
});
return { session: result.session, ...(hasCli ? {} : { notice: `${cli} is not installed; its tab says how to fix that` }) };
}
// Billion's board tools as it sees them, model lists included. Rewritten when
// a refresh finds new lists, so the copy its prompt points to stays current.
const BILLION_TOOLS_FILE = join(CONFIG_DIR, 'billion-tools.json');
const billionTools = () => toolsFor({ isBillion: true, agent: billionAgent() }, availableModels());
onModelsChange(() => {
if (!liveBillion()) return;
try { saveBoardTools(BILLION_TOOLS_FILE, billionTools()); } catch (err) {
console.error(`Billion: could not save the board tool definitions to ${BILLION_TOOLS_FILE}:`, err.message);
}
});
// Stops a running Billion and waits for it to go, handing back the mail it
// had waiting. SIGKILL after a few seconds, as at shutdown.
function stopBillion(session) {
const carried = takeMessages(session.id);
return new Promise((resolve) => {
const done = () => { clearTimeout(timer); session.exited = true; resolve(carried); };
const timer = setTimeout(() => {
try { process.kill(session.pty.pid, 'SIGKILL'); } catch {}
done();
}, 3000);
session.pty.onExit(done);
try { session.pty.kill(); } catch { done(); }
});
}
// Moves Billion to the other CLI, or to `to` (server/billion.js). One at a
// time: a second click while the first waits on the old one to exit would
// otherwise start a second Billion. `reason` is saved when the server made
// the switch itself (server/billion-limit.js).
let switching = null;
const BILLION_SWITCHING = 'Billion is already switching';
async function switchBillion(to, reason) {
if (switching) return { error: BILLION_SWITCHING };
const current = liveBillion() || [...sessions.values()].find(s => s.isBillion) || null;
switching = switchBillionSteps({
to, current, currentAgent: billionAgent(), dir: billionDir(),
writeHandover, saveAgent: (agent) => saveBillionAgent(agent, process.env, undefined, reason), stop: stopBillion, start: startBillion,
});
try { return await switching; } finally { switching = null; announceAccount(); }
}
// The owner's Claude account switch (server/account-migration.js): every
// action here is the browser's, gated in server/ws.js to the owner's browser
// alone; no board tool reaches any of it. The checks run first; only a switch
// that can start stops Billion (server/billion.js, withBillionStopped: its
// running claude would otherwise keep the old account's session and could
// write that account's token back) and starts it again after, on whatever
// login the swap left. A switch or rollback also holds the CLI-switch lock,
// so the button next to Billion's name cannot restart it mid-swap. The owner
// is told the emails and the result, never a token: on Telegram, and in the
// browser only for the armed switch (a click already gets its answer over the
// socket). Workers are left alone: claude re-reads its token every 30
// seconds; RECHECK_MS later the login is looked at again, in case one wrote
// the old account's token back first. The state goes to browsers only where
// the owner may act (user accounts off), as the actions themselves do.
const RECHECK_MS = 40_000;
const CLI_LABEL = { claude: 'Claude', codex: 'Codex' };
const rotationPayload = cli => ({ ...publicRotationState(undefined, undefined, cli), resumePending: [...sessions.values()].some(s => s.rotationResume && s.agent === cli) });
const accountStatePayload = () => ({ type: 'account-state', ...accountState(), rotation: rotationPayload('claude'), codexRotation: rotationPayload('codex'), billionCli: liveBillion()?.agent || null });
const announceAccount = () => { if (mayAnswerOwner()) broadcastToBrowsers(accountStatePayload()); };
async function tellOwnerOrShow(text, level, { show = true } = {}) {
// In the Billion tab either way; a toast too unless it reached the phone.
const result = await tellOwner(text, { broadcast, notice: true });
if (!result.telegram && show && mayAnswerOwner()) broadcastToBrowsers({ type: 'notification', level, message: text });
}
const workersOnClaude = () => [...sessions.values()].filter(s => !s.isBillion && !s.exited && s.agent === 'claude').length;
async function aroundBillion(fn) {
if (switching) return { error: BILLION_SWITCHING };
switching = withBillionStopped(fn, {
live: liveBillion, stop: stopBillion, start: startBillion,
announce: (session) => broadcast(sessionPayload(session)),
failed: (error) => tellOwnerOrShow(`Billion did not restart after the Claude account action: ${error}. Press Start next to Billion.`, 'error'),
});
try { return await switching; } finally { switching = null; }
}
// A Codex session's conversation, only while no other app Codex session
// shares its folder: the newest recorded there since it started (so a /new in
// the TUI counts), else the one its own `codex resume <id>` names (a rotation
// restart, Restart, a respawned orphan): Codex writes nothing to it until a
// turn runs.
const codexIdFor = session => {
const dir = session.worktreePath || session.cwd;
const shared = [...sessions.values()].some(s => s !== session && s.agent === 'codex' && (!s.exited || s.rotationResume) && (s.worktreePath || s.cwd) === dir);
if (shared) return null;
const args = parseCommand(session.command || '').args, at = args.indexOf('resume');
return codexSessionIdFor(dir, undefined, session.createdAt || -Infinity)
|| (at >= 0 && isCodexSessionId(args[at + 1]) ? args[at + 1] : null);
};
// Rotation restarts every managed session of that CLI in its own conversation.
// Session ids and job links stay stable, including queued mail and UI tabs.
async function aroundAgent(cli, fn) {
if (switching) return { error: BILLION_SWITCHING, busy: true };
blockSpawns(true, cli);
const check = () => assertClaudeProcessesManaged([...sessions.values()], { agent: cli });
switching = check().then(() => withClaudeSessionsStopped(async () => {
const { daemon } = await check();
// Codex's background server keeps the old login in memory: stopped with
// every app session down, it starts again on the new one.
if (daemon) {
await stopCodexDaemon();
if ((await check()).daemon) throw new Error('The Codex background server did not stop. No account was switched.');
}
return fn();
}, {
agent: cli,
list: () => [...sessions.values()],
idFor: session => (cli === 'codex' ? codexIdFor(session) : session.claudeSessionId),
stop: async session => {
if (session.exited && session.rotationResume) return takeMessages(session.id);
session.accountRotating = true;
const held = session.messagesHeld;
if (!session.rotationResume) session.rotationMessagesHeld = held;
session.messagesHeld = true;
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`A ${CLI_LABEL[cli]} process did not stop; account switching was cancelled.`)), 7000);
session.pty.onExit(() => { clearTimeout(timeout); resolve(); });
killSessionProcesses(session);
});
// killSessionProcesses also terminates detached tool children after 3s.
await new Promise(resolve => setTimeout(resolve, 3100));
return takeMessages(session.id);
} catch (err) {
// A late exit must retain mail and remain eligible for an exact retry.
session.rotationResume = true;
throw err;
}
},
start: ({ session, command, carried }) => {
clearTimeout(session.scanTimer);
session.rotationResume = true;
// Keep mail recoverable even if spawning the replacement fails.
restoreMessages(session.id, carried);
const result = createSessionFromConfig({ ...session, sessionId: session.id, command, rotationRestart: true, autoTrust: session.answersTrust }, broadcast);
if (result.error) return result;
result.session.rotationResume = false;
result.session.accountRotating = false;
result.session.messagesHeld = !!session.rotationMessagesHeld;
result.session.lastWakeAt = session.lastWakeAt;
result.session.wakeAt = session.wakeAt;
sessions.set(session.id, result.session);
if (session.worktreePath) { saveActiveSession(result.session, broadcast); startTreeScanLoop(result.session, broadcast); }
broadcast(sessionPayload(result.session));
return result;
},
failed: (session, error) => tellOwnerOrShow(`${session.name} could not resume after the account switch: ${error}`, 'error'),
}));
try { return await switching; }
catch (err) { return { error: err.message, blocked: true, busy: !!err.busy }; }
finally { switching = null; blockSpawns(false); requestDispatch(); }
}
async function rotate(cli, options = {}) {
const result = await rotateAccount({ ...options, around: fn => aroundAgent(cli, fn) }, { cli });
if (result.error === BUSY_ERROR || result.error === BILLION_SWITCHING) result.busy = true;
announceAccount();
if (result.ok && !result.unchanged) await tellOwnerOrShow(`${CLI_LABEL[cli]} account switched from ${result.oldEmail} to ${result.newEmail}. ${CLI_LABEL[cli]} conversations resumed.`, 'info');
return result;
}
// Every logged-in Claude and Codex login a scan finds joins the one Accounts
// list, Claude first, then Codex; a new one joins the end, unselected while
// switching is on. Only where the owner sees the list (Billion on, user accounts off).
async function enrollScanned(scan) {
if (!billionRuns()) return;
for (const cli of ROTATION_CLIS) {
for (const { folder, loggedIn } of scan.agents.find(a => a.cli === cli)?.accounts || []) {
if (!loggedIn) continue;
const result = await addRotationAccount(folder, { cli });
if (!result.error) continue;
const message = `${folder} was not added to ${CLI_LABEL[cli]} account switching: ${result.error}`;
console.error(`Accounts: ${message}`);
if (mayAnswerOwner()) broadcastToBrowsers({ type: 'account-error', message });
}
}
announceAccount();
}
setOnScan(scan => enrollScanned(scan).catch(err => console.error(`Accounts: enrolling scanned logins failed: ${err.message}`)));
// While Billion is out of the way for a swap, nobody starts another one under it.
const startBillionUnlessSwitching = async () => (switching ? { error: BILLION_SWITCHING } : startBillion());
async function switchAccount(how, { fromBrowser = false } = {}) {
const can = canMigrate();
if (can.error) return can;
const check = await checkSwitch(can.folder);
if (check.error) {
if (check.error !== BUSY_ERROR) await tellOwnerOrShow(`Claude account switch to ${accountState().newEmail || 'the new account'} not started: ${check.error}`, 'error', { show: !fromBrowser });
return check;
}
const result = await aroundBillion(() => migrateAccount(can.folder));
if (result.error === BUSY_ERROR || result.error === BILLION_SWITCHING) return result; // the other run tells the owner
const workers = workersOnClaude();
await tellOwnerOrShow(result.ok
? `Claude account switched${how ? ` (${how})` : ''}: the default Claude Code login is now ${result.newEmail}, was ${result.oldEmail}. Backup in ${result.backupDir}. Leave ${can.folder} alone; retire it from the app once you have checked the switch.${workers ? ` ${workers} Claude Code worker(s) were running; they pick the new token up within 30 seconds, and the login is checked again in ${RECHECK_MS / 1000} seconds.` : ''}`
: `Claude account switch to ${check.newEmail} failed: ${result.error}`, result.ok ? 'info' : 'error', { show: !fromBrowser });
if (result.ok) {
setTimeout(async () => {
const again = await recheckAccount();
if (again.error && again.error !== BUSY_ERROR) { // busy: another action is on it and reports itself
announceAccount();
await tellOwnerOrShow(`Claude account: ${again.error}. Roll back, then Switch now again once the workers are idle.`, 'error');
}
}, RECHECK_MS).unref?.();
}
return result;
}
const cliOf = msg => (msg.cli === 'codex' ? 'codex' : 'claude');
// Keys looked up with Object.hasOwn: a message naming a prototype key is not an action.
const accountActions = {
// msg.cli picks the registry: 'codex', else Claude's. The settings cover
// both; the scan enrolls logins (enrollScanned).
'rotation-add': msg => typeof msg.folder === 'string' ? addRotationAccount(msg.folder, { cli: cliOf(msg) }) : { error: cliOf(msg) === 'codex' ? 'Give a Codex home folder.' : 'Give a Claude config folder.' },
'rotation-configure': msg => configureRotation(msg),
'rotation-switch': msg => typeof msg.id === 'string' && /^[a-f0-9]{64}$/.test(msg.id) ? rotate(cliOf(msg), { id: msg.id }) : { error: `Select a saved ${CLI_LABEL[cliOf(msg)]} account.` },
'rotation-recover': msg => recoverRotation(fn => aroundAgent(cliOf(msg), fn), { cli: cliOf(msg) }),
'rotation-resume': msg => aroundAgent(cliOf(msg), async () => ({ ok: true })),
setup: (msg) => setupAccount(msg.folder),
arm: (msg) => armAccount(msg.on !== false),
migrate: () => switchAccount('by the owner', { fromBrowser: true }),
rollback: async () => {
const result = await aroundBillion(() => rollbackAccount());
if (result.error !== BUSY_ERROR && result.error !== BILLION_SWITCHING) {
await tellOwnerOrShow(result.ok ? `Claude account rolled back: the default Claude Code login is ${result.email} again.` : `Claude account rollback failed: ${result.error}`, result.ok ? 'info' : 'error', { show: false });
}
return result;
},
retire: () => retireAccount(),
};
async function accountAction(msg) {
const name = typeof msg.action === 'string' && Object.hasOwn(accountActions, msg.action) ? msg.action : null;
if (!name) return { error: `Unknown account action ${String(msg.action).slice(0, 40)}` };
const result = await accountActions[name](msg);
announceAccount();
return result;
}
// The server's operating loop for Billion (server/billion-wake.js): sooner
// while one of its cards is being worked, or just reached Review or finished CI.
let wakeTimer = null;
let accountLimitRunning = false;
// The CLI's own saved switch (Settings saves both alike) and a selected account of it.
const poolOn = cli => rotationState(undefined, cli).enabled && selectedCount(undefined, cli) > 0;
async function workerAccountLimitTick(now) {
if (accountLimitRunning || switching || !mayAnswerOwner()) return;
const clis = ROTATION_CLIS.filter(poolOn);
const session = [...sessions.values()].find(s => !s.exited && !s.isBillion && clis.includes(s.agent)
&& s.state !== 'WORKING' && now - (s.lastOutputAt || 0) >= SETTLE_MS
&& !(s.rotationRetryAt > now) && matchLimit(screenTail(s.ringBuffer.getAll().join(''), 15))?.kind === 'hard');
if (!session) return;
accountLimitRunning = true;
try {
const hit = matchLimit(screenTail(session.ringBuffer.getAll().join(''), 15));
const result = await rotate(session.agent, { limited: !session.rotationMarked, line: hit.retry, allowCurrent: !!session.rotationMarked });
if (result.busy) return;
session.rotationMarked = true;
if (result.exhausted || result.error) {
session.rotationRetryAt = Number.isFinite(result.retryAt) ? result.retryAt : now + 30 * 60_000;
if (!session.rotationNotified) { session.rotationNotified = true; await tellOwnerOrShow(result.error || `${CLI_LABEL[session.agent]} accounts are unavailable. Workers will retry after a usage reset.`, 'info'); }
}
} finally { accountLimitRunning = false; }
}
// An enabled account pool for limitTick, or null: switching on and a selected
// account of this CLI in the list. Billion hands over to the other CLI only
// when the list has a selected account of that one.
const pool = cli => (mayAnswerOwner() && poolOn(cli) ? {
run: (hit, { limited }) => rotate(cli, { limited, line: hit.retry ?? hit.line, allowCurrent: !limited }),
fallback: () => selectedCount(undefined, otherCli(cli)) > 0,
prepare: () => rotate(cli, { allowCurrent: true, preferCurrent: true }),
} : null);
function startBillionWakes() {
clearInterval(wakeTimer);
wakeTimer = setInterval(() => {
const now = Date.now();
workerAccountLimitTick(now).catch(() => console.error('Worker account rotation failed.'));
const session = liveBillion();
if (!session || accountLimitRunning || switching) return;
const busy = billionBusy(allJobs(), (job) => (job.agentSessionId ? sessions.get(job.agentSessionId) : null),
session.lastWakeAt || session.createdAt || 0, now);
wakeTick(session, { now, busy });
limitTick(session, {
now,
switchTo: async (to, reason) => {
const result = await switchBillion(to, reason);
if (!result.error && !result.existing) broadcast(sessionPayload(result.session));
return result.error === BILLION_SWITCHING ? { ...result, busy: true } : result;
},
// Billion stalled with no one to unstick it: super urgent, so the phone too.
notify: (text) => notifyOwner(text, { broadcast, telegram: true }),
// No Telegram: the browser's notice instead.
tell: (text) => tellOwnerOrShow(text, 'info'),
// Only while the owner may act (user accounts off): the session's own
// CLI moves to its next login before any move to the other CLI.
rotation: pool('claude'),
codexRotation: pool('codex'),
migration: {
armed: () => mayAnswerOwner() && !rotationState().accounts.length && !rotationState().damaged && accountArmed(),
run: (hit) => switchAccount(`armed: Claude Code said "${hit.line}"`).finally(announceAccount),
},
}).catch(err => console.error('Billion: usage-limit check failed:', err.message));
}, WAKE_TICK_MS);
wakeTimer.unref?.();
}
// Rounds (server/rounds.js) and the Billion tab's status line
// (server/billion-status.js): whether or not Billion runs, a round still
// comes due and the line still says so.
let roundTimer = null;
let stepTimer = null;
const sessionForJob = (job) => (job.agentSessionId ? sessions.get(job.agentSessionId) : null);
function startRounds() {
setStatusFacts(() => ({
billion: liveBillion(),
workers: allJobs().filter(job => job.postedByBillion && job.state === 'in-progress'
&& deriveJobStatus(job, sessionForJob(job)) === 'running').length,
nextRoundAt: comingRound()?.at ?? null,
}));
clearInterval(roundTimer);
let ticking = false;
roundTimer = setInterval(async () => {
if (ticking) return;
ticking = true;
try {
// Here, not in the wake loop: board workers run with Billion off too.
try { autoContinueWorkers(broadcast); } catch (err) { console.error('Worker auto-continue failed:', err.message); }
await roundTick({ broadcast });
publishStatus(broadcast);
} catch (err) {
console.error('Rounds: tick failed:', err.message);
} finally {
ticking = false;
}
}, WAKE_TICK_MS);
roundTimer.unref?.();
// The step log under a waiting message: the tools Billion uses, read off its
// screen this often. publishStatus sends only when something changed.
clearInterval(stepTimer);
stepTimer = setInterval(() => {
try {
if (noteScreenSteps().length) publishStatus(broadcast);
} catch (err) {
console.error('Billion: step log failed:', err.message);
}
}, STEP_TICK_MS);
stepTimer.unref?.();
}
// --- WebSocket ---
setupWebSocket(wss, { createSession, killSession, startBillion: startBillionUnlessSwitching, switchBillion, accountAction, accountState: accountStatePayload });
// --- Startup ---
async function startup() {
// Agent MCP configs are removed when their PTY exits; a crash or a restart
// never runs that handler, so clear whatever the last run left behind.
//
// Inside startup(), NOT at module scope: importing server.js must not delete
// anything. test/server.test.js imports this file before it sets PORT, so a
// module-scope sweep would target the default port and wipe the configs of a
// real server running on 7007 while the suite ran.
sweepMcpConfigs();
// Before anything spawns: a Codex worker on Billion's card is hooked only
// once Codex has told us the hook's hash (a second or so, 10 at most; skipped without codex).
await startCodexHookLookup();
loadConfig();
// Reserved whether or not it runs: no other agent may take the name that
// send_message delivers to Billion by.
codenamePool.reserve(BILLION_NAME);
recoverCrashedSessions(broadcast);
mkdirSync(WORKTREE_DIR, { recursive: true });
await pruneWorktrees();
await scanForOrphanedWorktrees(broadcast);
// Not awaited: each check can ask the remote, and boot must not wait on the network.
releasePushedOrphans(broadcast).then(n => { if (n) console.log(` Released ${n} orphaned worktree(s) now on the remote`); })
.catch(err => console.error('Orphan re-check failed:', err.message));
// The loop always runs; each tick is a no-op while settings.running is false.
// Keeping one timer alive (instead of creating/destroying it on toggle) means
// the Start button only has to flip a boolean, and a config restored with
// running:true resumes dispatching without any extra wiring.
// Before the dispatcher: a card's model is checked against this list.
startModelRefresh();
// The scan the start's doctor check began (bin/agent-007.js), or a new one.
agentAccounts();
// A once schedule not yet fired becomes a one-time card with its start time;
// then one-date schedules ("0 10 24 9 *") that have already run are done:
// archived here, each one logged, rather than left showing next year's date.
convertOnceSchedules(broadcast);
retireSpentSchedules(broadcast);
// One skill store, when the owner turned it on (server/skill-store.js).
autoSync();
// Claude Code's own skill list for the built-in skill family, probed in the
// background when this version has none cached (server/skill-listing.js).
if (envSwitchOn(process.env.SKILL_FAMILIES)) refreshListing();
startDispatcher(createSession, broadcast, {
onSessionCreated: (s) => broadcast(sessionPayload(s)),
killSession,
// Billion's workers orphaned by this restart come back on the first scan.
respawnWorkers: () => respawnBoardWorkers(),
});
if (boardSettings().running) console.log(' Job board dispatcher: running');
if (process.env.RESPAWN_BOARD_WORKERS === '0') console.log(' Board workers stay orphaned after a restart (RESPAWN_BOARD_WORKERS=0)');
// A misspelt mode would otherwise be ignored without a word.
for (const [agent, key] of Object.entries(ENV_PERMISSION_MODE)) {
const raw = (process.env[key] || '').trim();
if (raw && !envPermissionMode(agent)) console.warn(` ${key}=${raw} is not a permission mode (${PERMISSION_MODES.join(', ')}); ignored`);
else if (raw) console.log(` ${agent} agents start in ${raw} unless told otherwise`);
}
if (billionRuns()) {
const warning = billionAgentWarning();
if (warning) console.warn(` ${warning}`);
const { error, notice, session } = await startBillion();
startBillionWakes();
console.log(error ? ` Billion: not started (${error})` : notice ? ` Billion: ${notice}` : ` Billion: running on ${session.agent} in ${billionDir()}`);
} else if (billionEnabled()) {
console.log(' Billion: off while user accounts are enabled');
}
// After Billion, so a reply waiting in Telegram finds it running.
startTelegram({ broadcast });
startRounds();
server.listen(PORT, HOST, () => {
// Bracket IPv6 literals so the URL is valid/clickable; show wildcard binds as localhost.
const bracket = (h) => h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
const displayHost = WILDCARD_BIND_HOSTS.includes(HOST) ? 'localhost' : bracket(HOST);
writeServerFile({ port: PORT, host: HOST, token: controlToken });
console.log(`\n Agent 007 is running at http://${displayHost}:${PORT}`);
if (PUBLIC_URL) console.log(` Behind a reverse proxy at ${PUBLIC_URL} (PUBLIC_URL); the proxy is what keeps strangers out.`);
else if (process.env.PUBLIC_URL?.trim()) console.warn(` PUBLIC_URL=${process.env.PUBLIC_URL.trim()} is not an http(s) URL; ignored`);
if (!LOOPBACK_HOSTS.includes(HOST)) {
console.log(` Listening on ${bracket(HOST)}:${PORT} — reachable from other machines. Keep this behind Tailscale/a trusted network.`);
}
console.log('');
});
}
// --- Graceful Shutdown (B10) ---
// Wait for PTY processes to exit with 3s timeout, then force kill.
// restart: exit with RESTART_EXIT, which the service (or bin/agent-007.js in
// a terminal) answers by starting the server again. The port is let go first.
// Called as a signal handler too, with the signal's name.
function gracefulShutdown({ restart = false } = {}) {
console.log(restart ? '\nRestarting...' : '\nShutting down...');
const code = restart ? RESTART_EXIT : 0;
if (restart) {
server.close();
for (const ws of wss.clients) ws.terminate();
server.closeAllConnections();
}
stopDispatcher();
stopTelegram();
clearInterval(wakeTimer);
clearInterval(roundTimer);
clearInterval(stepTimer);
const killPromises = [];
for (const [, session] of sessions) {
clearInterval(session.stateCheckInterval);
clearTimeout(session.scanTimer);
if (!session.exited) {
killPromises.push(new Promise((resolve) => {
const timer = setTimeout(() => {
try { process.kill(session.pty.pid, 'SIGKILL'); } catch {}
resolve();
}, 3000);
session.pty.onExit(() => { clearTimeout(timer); resolve(); });
try { session.pty.kill(); } catch { clearTimeout(timer); resolve(); }
}));
}
}
if (killPromises.length === 0) { process.exit(code); return; }
Promise.all(killPromises).then(() => process.exit(code));
// Hard deadline: exit after 5s no matter what
setTimeout(() => process.exit(1), 5000).unref();
}
// --- Exports for testing ---
export { app, server, wss, startup, gracefulShutdown, sessions, createSession, killSession, startBillion, switchBillion };
// Auto-start when run directly
if (isDirectRun(import.meta.url, process.argv[1])) {
startup();
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
} else if (process.argv[1] && basename(process.argv[1]) === basename(fileURLToPath(import.meta.url))) {
// The launched file has this file's name but the URLs still differ — a
// path-resolution miss, not a deliberate import. Say so instead of exiting
// 0 with no output (the failure mode this guard has silently hit before).
console.error(
`server.js entry-point guard mismatch: ${pathToFileURL(process.argv[1]).href} vs ${import.meta.url} — not auto-starting.`
);
}