Глава 2: Быстрый старт — Пайплайн bootstrap

Если глава 1 дала вам карту архитектуры Агента, то эта глава показывает маршрут, по которому он доходит до рабочего состояния. Каждый компонент из шести абстракций — цикл запроса, система инструментов, уровни состояния, hooks, память — должен быть инициализирован до того, как пользователь увидит курсор. Бюджет на всё это: 300 миллисекунд.

Триста миллисекунд — это порог, после которого люди перестают воспринимать инструмент как мгновенный. Перешагните его, и CLI начинает казаться медленным. Сильно промахнитесь, и разработчики перестанут им пользоваться. Всё в этой главе существует ради того, чтобы остаться ниже этой линии.

Bootstrap должен сделать четыре вещи: проверить окружение, установить границы доверия, настроить слой коммуникации и отрисовать UI. И всё это — меньше чем за 300 мс. Архитектурная идея здесь в том, что эти четыре задачи можно частично наложить друг на друга, аккуратно упорядочить и агрессивно урезать так, чтобы уложиться в бюджет, который для такой сложной системы кажется невозможным.

Небольшая оговорка по методологии: временные метки в этой главе приблизительны и получены из собственных чекпоинтов профилирования кодовой базы. Они отражают типичные тёплые старты на современном железе. Холодные старты медленнее. Абсолютные числа важны меньше, чем относительная структура: какие операции перекрываются, какие блокируют, а какие откладываются.


Форма пайплайна

Пайплайн запуска живёт в пяти файлах, которые выполняются последовательно. Каждый файл сужает круг задач, которые системе нужно решить дальше:

Каждый файл делает минимум работы, необходимый перед передачей управления следующему. cli.tsx пытается выйти до импорта чего-либо тяжёлого. main.tsx запускает медленные операции как побочные эффекты во время вычисления импорта. init.ts разрешает конфигурацию и устанавливает границу доверия. setup.ts регистрирует возможности. replLauncher.ts выбирает правильную точку входа и запускает UI.

Три стратегии параллелизма делают это быстрым:

  1. Маршрутизация subprocess на уровне модуля. Запуск чтения keychain и MDM как побочных эффектов во время вычисления импорта. Подпроцессы работают, пока загружаются оставшиеся ~135 мс статических импортов.
  2. Параллелизм промисов в setup. Привязка сокетов, снимки hooks, загрузка команд и загрузка определений агентов выполняются одновременно.
  3. Отложенный prefetch после рендера. Всё, что пользователю не нужно до первого сообщения — git status, возможности модели, AWS credentials — запускается после того, как prompt уже виден.

Четвёртая стратегия заметна меньше, но не менее важна: динамические импорты, откладывающие вычисление модулей. Кодовая база использует await import('./module.js') как минимум в дюжине мест, чтобы не загружать код, пока он не понадобится. OpenTelemetry (400 KB + 700 KB gRPC) загружается только когда инициализируется telemetry. React-компоненты загружаются только при рендеринге. Каждый динамический импорт меняет местами задержку на холодном пути (первое использование запускает вычисление модуля) и скорость горячего пути (старт не платит за модули, которые могут никогда не понадобиться).


Фаза 0: Быстрая маршрутизация (cli.tsx)

Первый файл, в который входит процесс, cli.tsx, делает одну вещь: определяет, нужен ли вообще полный bootstrap-пайплайн. Многие вызовы — agent --version, agent --help, agent mcp list — требуют конкретного ответа и ничего больше. Загружать React, инициализировать telemetry, читать keychain и поднимать систему инструментов было бы чистой тратой.

Паттерн такой: проверить argv, динамически импортировать только нужный обработчик и выйти до загрузки остальной системы.

// Псевдокод для паттерна быстрого пути
if (args.length === 1 && args[0] === '--version') {
  const { printVersion } = await import('./commands/version.js')
  await printVersion()
  process.exit(0)
}

Есть примерно дюжина быстрых путей, покрывающих версию, help, конфигурацию, управление MCP-серверами и проверки обновлений. Сами детали не важны — важен паттерн. Каждый путь динамически импортирует ровно один модуль, вызывает ровно одну функцию и завершается. Остальная кодовая база вообще не загружается.

Это первый случай принципа, который повторяется по всему bootstrap: делать меньше, зная больше о намерении. Массив argv раскрывает намерение пользователя. Если намерение узкое, путь исполнения тоже должен быть узким.

Если ни один быстрый путь не совпал, cli.tsx проваливается к полному импорту main.tsx, и настоящий запуск начинается.


Фаза 1: I/O на уровне модуля (main.tsx)

Когда main.tsx импортируется, его побочные эффекты на уровне модуля срабатывают во время вычисления — до вызова любой функции из файла. Это самая критичная по производительности техника во всём bootstrap:

// Это запускается во время импорта, а не во время вызова
const mdmPromise = startMDMSubprocess()
const keychainPromise = readKeychainCredentials()

Пока JavaScript-движок вычисляет остальной main.tsx и его транзитивные импорты (~138 мс вычисления модулей), эти два промиса уже в полёте. Подпроцесс MDM (Mobile Device Management) проверяет корпоративные политики безопасности. Чтение keychain извлекает сохранённые credentials. Оба — I/O-связанные операции, которые иначе сериализовались бы на критическом пути.

Идея здесь проста: вычисление модуля — это не пустое время, а время, которое можно наложить на I/O. К моменту первого вызова экспортируемых функций main.tsx эти промисы часто уже завершены.

Этот приём требует подавления правил ESLint top-level-await и side-effect-in-module-scope в соответствующих файлах. В кодовой базе есть собственное правило ESLint специально для паттернов доступа к process.env, которое разрешает контролируемые побочные эффекты в области модуля, но запрещает неконтролируемые где-либо ещё.


Фаза 2: Парсинг и доверие (init.ts)

Функция init() мемоизирована — вызывать её несколько раз безопасно, и результат будет тот же самый. Это важно, потому что несколько точек входа (REPL, режим печати, SDK-режим) могут каждая вызвать init(), а мемоизация гарантирует, что она выполнится ровно один раз.

Функция разрешает аргументы командной строки через Commander, загружает конфигурацию из нескольких источников (глобальные настройки, настройки проекта, переменные окружения), а затем упирается в самую важную границу в пайплайне.

Граница доверия

До границы доверия система работает в ограниченном режиме. После неё доступны полные возможности. Граница нужна потому, что Агент читает переменные окружения — а переменные окружения можно отравить.

Граница доверия — не про то, доверяет ли пользователь Агенту. Она про то, доверяет ли Агент окружению. Вредоносный .bashrc мог бы выставить LD_PRELOAD, чтобы внедрять код в каждый подпроцесс. Диалог доверия гарантирует, что пользователь явно соглашается работать в директории, которая могла быть настроена кем-то другим.

В системе есть десять операций, чувствительных к доверию. До принятия диалога доверия выполняются только безопасные операции: настройка TLS-сертификатов, предпочтения темы, отказ от telemetry. После доверия система читает потенциально опасные переменные окружения (PATH, LD_PRELOAD, NODE_OPTIONS), выполняет git-команды и применяет полную конфигурацию окружения.

Hook preAction

Hook preAction у Commander — это архитектурный замковый камень. Commander парсит структуру команд (флаги, подкоманды, позиционные аргументы), не выполняя ничего. Hook preAction срабатывает после парсинга, но до запуска обработчика совпавшей команды:

program.hook('preAction', async (thisCommand) => {
  await init(thisCommand)
})

Такое разделение означает, что быстрые команды (обработанные в cli.tsx до загрузки Commander) никогда не платят стоимость init(). Только команды, которым нужен полный окруженческий контекст, запускают инициализацию.


Фаза 3: Setup (setup.ts)

После завершения init() функция setup() регистрирует все возможности, которые нужны системе:

Команды, агенты, hooks и plugins регистрируются параллельно там, где это возможно. Фаза setup — это место, где система переходит от «я знаю свою конфигурацию» к «у меня есть все мои возможности». После setup зарегистрирован каждый инструмент, подключен каждый hook, и система готова принимать ввод пользователя.

Setup также обрабатывает снимок security hook. Конфигурация hooks читается с диска один раз, замораживается в неизменяемый снимок и используется всю остальную сессию. Поздние изменения файла конфигурации hooks на диске игнорируются. Это предотвращает сценарий, когда атакующий меняет правила hooks после старта сессии — замороженный снимок остаётся единственным источником истины для решений о разрешениях.


Фаза 4: Запуск (replLauncher.ts)

Семь разных кодовых путей сходятся в replLauncher.ts: интерактивный REPL, режим печати (--print), SDK-режим, resume (--resume), continue (--continue), pipe mode и headless-режим. Launcher проверяет конфигурацию, полученную от init(), и направляет выполнение в правильную точку входа.

Два примера показывают диапазон:

Интерактивный REPL — стандартный случай. Launcher монтирует дерево React/Ink, запускает терминальный рендерер и входит в event loop. Пользователь видит prompt и может начать печатать.

Режим печати (--print) — один prompt из argv. Launcher создаёт headless-цикл запроса без React-дерева, прогоняет его до завершения, стримит вывод в stdout и выходит. Тот же цикл агента, другая презентация.

Важная деталь: все семь путей в итоге вызывают query() — тот же самый цикл агента из главы 1. Путь запуска определяет как цикл представлен (интерактивный терминал, одиночный прогон, SDK-протокол), а не что он делает. Именно эта схлопнутость делает архитектуру тестируемой и предсказуемой: независимо от того, как пользователь вызывает Агента, базовое поведение одинаково.


Хронология запуска

Вот как выглядит полный пайплайн во времени:

Критический путь проходит через вычисление модулей (самая длинная фаза, ~138 мс), затем Commander parse, init и setup. Параллельные I/O-операции (MDM, keychain) перекрываются с вычислением модулей и обычно завершаются раньше, чем потребуются.

Бюджет производительности

ФазаВремяЧто происходит
Проверка быстрого пути~5msПроверка argv, ранний выход при возможности
Вычисление модулей~138msИмпорт дерева, запуск параллельного I/O
Commander parse~3msПарсинг флагов и подкоманд
init()~14msРазрешение конфигурации, граница доверия
setup()~35msКоманды, агенты, hooks, plugins
Запуск + первый render~25msВыбор пути, монтирование React, первый paint
Итого~240msМеньше бюджета 300ms

Итого получается примерно 240 мс на современной машине — запас 60 мс до бюджета 300 мс. Холодные старты (первый запуск после перезагрузки, пустой кэш ОС) могут поднимать вычисление модулей до 200+ мс, приближая общий результат к лимиту.


Система миграций

Кратко об одном подсистемном механизме, который работает во время init: миграции схемы. Агент хранит конфигурацию и данные сессий в локальных файлах и директориях. Когда формат меняется между версиями, миграции выполняются автоматически при запуске.

Каждая миграция — это функция с номером версии. Система сравнивает текущую версию схемы с самой высокой версией миграции, запускает ожидающие миграции по порядку и обновляет версию. Миграции идемпотентны и быстры (работают с небольшими локальными файлами, а не с базами данных). Весь проход миграций обычно завершается меньше чем за 5 мс. Если миграция падает, ошибка логируется, и выполнение продолжается — для локальной конфигурации доступность важнее строгой согласованности.


Что bootstrap говорит о дизайне системы

Пайплайн bootstrap — это исследование по сужению областей. Каждая фаза уменьшает пространство возможностей:

  • Фаза 0 сужает от «любого CLI-вызова» к «нужен полный bootstrap»
  • Фаза 1 сужает от «всё должно загрузиться» к «загружать параллельно с I/O»
  • Фаза 2 сужает от «неизвестного окружения» к «доверенному, настроенному окружению»
  • Фаза 3 сужает от «нет возможностей» к «всё зарегистрировано»
  • Фаза 4 сужает от «семи возможных режимов» к «одному конкретному пути запуска»

К моменту отрисовки REPL все решения уже приняты. Цикл запроса получает полностью настроенное окружение без двусмысленности относительно того, в каком он режиме, какие инструменты доступны и какие разрешения действуют. Бюджет 300 мс — это не просто цель производительности, а forcing function, которая не даёт bootstrap превратиться в систему ленивой инициализации, где решения откладываются и размазываются по всей кодовой базе.


Примените это

Перекрывайте I/O с инициализацией. Запускайте медленные операции (spawn subprocess, чтение credentials, сетевые проверки) во время вычисления модуля, до того как они понадобятся. JavaScript-движок и так делает синхронную работу — используйте это время для параллельного I/O. Паттерн: const promise = startSlowThing() вверху файла, await promise в точке использования.

Сужайте область как можно раньше. Пять файлов bootstrap образуют воронку: каждая фаза убирает работу, которая не нужна следующей. Быстрая маршрутизация — самый заметный пример, но принцип работает везде. Если на этапе парсинга можно определить, что путь исполнения не нужен, пропустите его.

Явно устанавливайте границы доверия. Если приложение читает окружение, которым не управляет (переменные окружения, конфиги, shell-настройки), проведите чёткую линию между «безопасно читать до согласия пользователя» и «читать только после согласия». Граница доверия предотвращает класс атак, где вредоносное окружение отравляет приложение до того, как пользователь успевает его оценить.

Сделайте init-функцию мемоизированной. Инициализация должна быть идемпотентной — вызов дважды даёт тот же результат. Это убирает баги порядка, когда несколько точек входа могут по отдельности запускать инициализацию. Паттерн мемоизации тривиален, но убирает целый класс багов двойной инициализации.

Сохраняйте ранний ввод до первого yield. В event-driven системе пользовательский ввод, пришедший во время инициализации, может потеряться. Агент захватывает начальный prompt из argv до начала любой асинхронной работы, гарантируя, что agent "fix the bug" не потеряет prompt, если инициализация займёт больше времени, чем ожидалось.