Ctrl K
ComunidadGuías…

server.js

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

Archivo original ↗Preguntar a KiBot
#!/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.`
  );
}