Skip to content

Моды ​

Экспериментально

ModApi новый и ещё только формируется. Поверхности и сигнатуры могут меняться, пока мы учимся на том, как моды пишут, — поэтому фиксируйте то, что выпускаете, и рассказывайте, чего не хватает. Именно ваша обратная связь делает его стабильным.

Моды — это TypeScript-пакеты, которые позволяют менять почти любое поведение Command Code. Они куда мощнее API плагинов и расширений, которые вы найдёте в других агентах, — хотя с их помощью вполне можно собрать плагин или расширение. Каждый мод пишется против ModApi: это обычный TypeScript-файл, который Command Code находит на диске, загружает при старте и компилирует в свой цикл агента. Один файл может добавить инструменты, которые вызывает модель, слэш-команды, мутирующие хуки жизненного цикла, наблюдателей событий, перехват типизированного ввода, ответы на запросы разрешений, редактирование транскрипта, маршрутизацию модели по запросам, панели и виджеты с кнопками, тосты, типы субагентов, настраиваемые флаги и провайдеров моделей. Моды перезагружаются на лету, когда вы их сохраняете. Собственные встроенные возможности Command Code — провайдеры, автоименование сессий, уведомление об обновлении — сделаны как моды против этого же API. Если Command Code умеет что-то, мод может это изменить.

Загружаемый мод после загрузки и есть AgentMod — хост модов просто собирает его из файла-фабрики, а не из импорта кода:

text
~/.commandcode/mods/review-guard.ts
│ one file = one mod
▼ jiti · TypeScript, no build step
default-export factory(cmd: ModApi)
│
▼ createModHost().register(...)
hooks + addTool ──► ONE AgentMod
                    id `mod:<name>`,
                    appended after built-ins
addCommand ──► /slash dispatch
               + TUI autocomplete
on(event) ──► AgentEvent bus
              (observe-only)
hooks.transformInput ──► typed-prompt
                         interception
addProvider ──► ProviderModule
addRenderer ──► custom feed entries
queueMessage ──► steering drains
ui.widget / ui.pane ──► bands, panes, buttons
                        (element tree, any host)
addAgent ──► task tool agent types
prompt.submit ──► a turn of its own

Фабрика получает API, привязанный как cmd. Все регистрирующие методы начинаются с add*, и каждый возвращает Disposable — вызовите .dispose(), чтобы отменить ровно эту регистрацию.

Эта страница описывает всю поверхность модов целиком: сначала быстрый старт и правила загрузки, затем полный справочник ModApi, контракт хуков и событий, UI-поверхность, упаковку и установку и то, как проверить мод. Перейти в любой раздел можно из бокового меню.

Спросите Command Code, чтобы он собрал мод ​

Писать мод вручную почти никогда не требуется. Command Code уже знает весь этот API: опишите нужное поведение, и он напишет мод, загрузит его через --mod и будет дорабатывать, пока не заработает. Самый быстрый вход — спросить, что вообще возможно, а затем попросить конкретный мод:

text
What kinds of mods can I build for Command Code? Show me a few examples.
text
Build me a mod that roasts me every time I greet you with "hi".
text
Add a /standup slash command that summarizes what changed in git today.
text
Block any shell command containing "rm -rf" unless I confirm it first.

Под капотом он читает встроенные примеры и пишет код против того же ModApi, который описан на этой странице. Когда всё готово, cmd --mod ./your-mod.ts сразу его пробует — без шага сборки.

Настоящий мод, собранный по запросу ​

cmd-mod-hi начался ровно с такого промпта — «подкалывай меня, когда я говорю hi» — и вышел в npm. Установите его и поздоровайтесь с агентом:

bash
cmd mods add npm:cmd-mod-hi -g

Затем введите hi, yo, howdy или hello и посмотрите, как он вас оценивает; /hi-stats подсчитывает урон. Весь мод — это один хук transformInput плюс рендерер:

ts
import type { ModApi } from '@commandcode/harness'

const roasts = [
  '"hi" — bold. groundbreaking. truly pushing the boundaries of human communication.',
  'you typed "hi" and pressed enter. your ancestors are weeping.'
  // …a dozen more
]

let greetingCount = 0

export default function (cmd: ModApi): void {
  cmd.addRenderer('hi-roast', (data: { text: string }) => ['[cmd-mod-hi]: ' + data.text])

  cmd.addCommand({
    name: 'hi-stats',
    description: 'See how many times you\'ve been roasted for saying "hi"',
    handler: () => ({ message: `[cmd-mod-hi]: You've been roasted ${greetingCount} times.` })
  })

  cmd.hooks({
    transformInput({ text }) {
      if (/^(hi|hello|hey|yo|sup|howdy)[.!?\s]*$/.test(text.trim().toLowerCase())) {
        greetingCount += 1
        cmd.showEntry('hi-roast', { text: roasts[greetingCount % roasts.length] })
      }
      return undefined // let the greeting through - the roast is a side show
    }
  })
}

Хотите понять, что он делает, или написать свой вручную? Дальше идёт полный справочник.

Быстрый старт ​

Создайте ~/.commandcode/mods/review-guard.ts:

ts
import type { ModApi } from '@commandcode/harness'

export default function (cmd: ModApi) {
  // Block dangerous writes (a mutating hook - see the hooks catalog).
  cmd.hooks({
    beforeToolCall: async ({ toolName, input }) => {
      if (toolName !== 'shell_command') return undefined
      const command = typeof input.command === 'string' ? input.command : ''
      if (!command.includes('rm -rf')) return undefined

      const allow = await cmd.ui.confirm({ title: 'Allow rm -rf?' })
      return allow ? undefined : { block: true, additionalContext: 'Blocked by review-guard.' }
    }
  })

  // A tool the model can call.
  cmd.addTool({
    schema: {
      name: 'count_todos',
      description: 'Count TODO markers in the repo',
      input_schema: { type: 'object', properties: {}, required: [] }
    },
    run: async () => {
      const result = await cmd.exec({ command: 'grep', args: ['-rc', 'TODO', '.'] })
      return { ok: true, content: [{ type: 'text', text: result.stdout }] }
    }
  })

  // A host slash command: /todos in the TUI.
  cmd.addCommand({
    name: 'todos',
    description: 'Summarize open TODOs',
    handler: () => ({ prompt: 'List every TODO comment in this repo and rank by urgency.' })
  })

  // Observe the event stream (never mutates - mutation is what hooks are for).
  cmd.on('turn_end', () => cmd.ui.notify('turn finished'))
}

Мод загрузится в следующей сессии (или cmd --mod ./review-guard.ts, чтобы попробовать без установки). Фабрика может быть асинхронной; jiti компилирует TypeScript во время загрузки, поэтому шага сборки нет.

Откуда загружаются моды ​

РасположениеОбластьПримечания
built-inпоставляемыескомпилированные first-party моды (провайдеры, автоименование, …); регистрируются первыми
~/.commandcode/mods/*.tsпользовательотдельные файлы, по одному моду в каждом
~/.commandcode/mods/<dir>/пользовательманифест package.json → каталог mods/ → index.ts
<project>/.commandcode/mods/…проектта же структура; загружается только после подтверждения доверия к рабочему пространству
настройки mods.pathsпользователь/проектявные файлы и каталоги относительно области настроек
настройки mods.sourcesпользователь/проектустановленные пакеты (см. Установка и обмен модами)
--mod <path>сессияможно повторять; загружается раньше установленных модов и выигрывает коллизии имён

Точечные записи и node_modules никогда не сканируются (реестр пакетов лежит в mods/.registry/ именно затем, чтобы обнаружение его пропускало). При совпадении имён остаётся первый мод, а выводится предупреждение. Отключить мод, не удаляя его, можно настройками:

json
{ "mods": { "disabled": ["review-guard"] } }

Отключить сразу все установленные моды можно через {"mods": {"disableAll": true}} (в любом файле настроек; отключение побеждает), а для одной сессии — через cmd --safe-mode. Оба варианта всё равно загружают встроенные моды, которые и есть сам Command Code; безопасный режим дополнительно пропускает пути --mod.

Одно правило: хуки меняют, on наблюдает ​

  • cmd.hooks({...}) — единственное место, которое может менять поведение: блокировать, переписывать или отвечать на вызов инструмента (beforeToolCall), переписывать результат (afterToolCall), дополнять или править системный промпт (appendSystemPrompt, transformSystemPrompt), вычищать то, что остаётся в транскрипте (transformMessage), переписывать ответ по мере его потока (transformTextDelta), отправлять один запрос другой модели (beforeModelRequest), отвечать на запрос разрешения (onPermissionRequest), переписывать типизированный ввод (transformInput), заставлять завершённый запуск продолжаться (onStop), реагировать на старт и конец сессии (onSessionStart/onSessionEnd) или выполнять работу после хода (onRunEnd). Несколько вызовов hooks() складываются в порядке регистрации.
  • cmd.on(event, ...) только наблюдает — он не может блокировать или переписывать. Обработчики изолированы: выброшенное исключение становится событием mod_error, а не падением.

Если вам захотелось, чтобы обработчик on остановил инструмент, — вам нужен хук. Полная мутирующая поверхность описана в разделе Хуки и события, полная поверхность регистрации и живых методов — в справочнике ModApi.

Встроенные моды ​

Собственные возможности Command Code работают на этом же API. First-party провайдеры (provider-anthropic / provider-copilot / provider-openai), уведомление об обновлении, а также триггеры автоименования и обучения вкусу (taste) на стороне харнесса — это встроенные моды: скомпилированные фабрики, зарегистрированные на том же хосте раньше любого обнаруженного мода. Они ничем не особенны — используют cmd.addProvider, cmd.on и cmd.hooks как любой мод, видны в cmd mods list с источником builtin и подчиняются тому же ключу отключения:

json
{ "mods": { "disabled": ["provider-copilot", "update-notice", "titling", "learning"] } }

Некоторые встроенные моды опциональны и по умолчанию отключены; cmd mods list --builtin помечает их как disabled by default. Чек-лист задач сессии (todo_write, панель TODOS, /todos, ctrl+x) — это мод todos: включите его командой cmd mods enable todos (на уровне проекта; -g — на уровне пользователя), которая добавляет его в ключ enabled файла настроек:

json
{ "mods": { "enabled": ["todos"] } }

cmd mods disable <name> отключает мод. Отключение в любом файле настроек побеждает и оставляет мод выключенным. Изменение вступает в силу в следующей сессии или по /reload; см. Включение и выключение мода.

Встроенные моды всегда выигрывают коллизию имён у обнаруженного мода с тем же именем (загрузчик затеняет файл с предупреждением), и они скомпилированы внутрь — никогда не загружаются jiti из записываемого пути, поэтому не являются поверхностью атаки цепочки поставок. Структурные моды харнесса (рабочее пространство, компакция, чекпоинты) критичны для корректной работы и остаются безусловными; отключать можно только observer-встроенные моды, перечисленные выше.

Запускаемые примеры ​

Command Code поставляет запускаемые однофайловые моды-примеры и сам их читает. Искать их на диске не нужно — просто попросите:

text
build a mod that blocks dangerous shell commands

Command Code откроет подходящий пример и будет работать от него. Каждый пример в CI проходит через настоящий загрузчик модов на каждом тестовом прогоне, так что скопированное гарантированно загружается.

Просите любой из них по имени или по тому, что вам нужно:

ФайлЧто показывает
slash-command.tsaddCommand — команда /command, возвращающая {prompt} или {message}
custom-tool.tsaddTool — инструмент, вызываемый моделью, с run + exec
block-dangerous-commands.tshooks.beforeToolCall — блокировка и разрешение с подтверждением
input-shortcuts.tshooks.transformInput — переписывание и обработка типизированного ввода
observe-events.tson(event) + кросс-модовая шина events
custom-entry-renderer.tsaddRenderer + showEntry — стилизованные строки ленты
flags-and-options.tsaddFlag / getFlag + --mod-option
lifecycle-hooks.tshooks.onStop (Stop) + onSessionStart/onSessionEnd + afterToolCall isError + on('subagent_start'/'subagent_stop')
pane-and-buttons.tsui.pane + ui.widget + кнопки + cmd.state + ui.toast
pane-form.tsпанель-форма в фокусе: элементы input + select + markdown, openPane(id, {focus: true})
spinner-count.tsui.override — счётчик вызовов инструментов рядом со спиннером, компактные строки shell
redact-secrets.tshooks.transformMessage — маскирование ключей до того, как их сохранит транскрипт
permission-policy.tshooks.onPermissionRequest — ответы на запросы по политике команды
model-router.tshooks.beforeModelRequest + cmd.model.complete
stream-filter.tshooks.transformTextDelta — вычищение ключей по мере потока ответа + cmd.process.spawn
kitchen-sink.tsвсе возможности в одном аннотированном файле

Чего моды не могут ​

Эти ограничения намеренные.

  • Хуки меняют, on наблюдает. Обработчики событий не могут блокировать инструменты или переписывать контекст — для этого есть cmd.hooks.
  • Моды проекта закрыты проверкой доверия, как и навыки проекта: они загружаются только после запроса доверия к рабочему пространству, потому что мод — это произвольный код. Моды уровня пользователя и моды из --mod загружаются всегда. Песочницы нет — устанавливайте только те пакеты, которым доверяете. Установка пакетов запускает npm с --ignore-scripts (моды — это TypeScript, загружаемый через jiti; шаг сборки им не нужен, поэтому lifecycle-скрипты здесь чистая поверхность атаки).
  • Print-режим загружает только моды уровня пользователя и моды из --mod, а UI-мост деградирует до headless-значений по умолчанию (confirm → false, select/input → undefined — автоматического подтверждения не бывает; setStatus/widget нигде не рисуются, а cmd.ui.capabilities.status равен false). Моды проекта не попадают в headless-запуски, потому что print никогда не показывает запрос доверия; передайте --dangerously-skip-permissions, чтобы разрешить модам самого репозитория участвовать в headless-запуске (CI).
  • Сообщения, поставленные модом в очередь, не отражаются в ленте так, как типизированный ввод: они попадают в транскрипт и направляют модель, но видимая запись — это ответ модели.
  • Отрисовка — это данные, а не компоненты. Виджеты, панели и рендереры ленты возвращают строки или небольшое дерево элементов (text, row, box, button, progress, divider, input, select, markdown), которое рисует каждый хост; моды никогда не монтируют React-компоненты. Это делает мод независимым от хоста, а падающая отрисовка деградирует до однострочного предупреждения и никогда не ломает экран.
  • Моды перезагружаются на лету при сохранении. В интерактивном TUI каждый файловый мод находится под наблюдением: сохранение переимпортирует файл и подменяет мод на месте. Старый код получает onSessionEnd с reason: 'reload', и все его регистрации освобождаются, кроме инструментов, добавленных через addTool, — они держат старый код до следующей сессии. cmd.state сохраняется, а onSessionStart нового кода срабатывает с source: 'reload'. Сохранение, которое падает при загрузке, оставляет работать старый код, а лента объясняет причину. /mods reload [name] делает то же по требованию; COMMANDCODE_MODS_HOT_RELOAD=0 отключает наблюдатель. Инструменты, которые добавляет мод, вступают в силу в следующей сессии, потому что набор инструментов фиксирован на сессию. Встроенные моды никогда не перезагружаются.
  • Моды переоформляют строки, но никогда — запрос разрешения. cmd.ui.override покрывает спиннер, строки инструментов, сообщения пользователя и вывод команд. То, что человеку предлагают одобрить, всегда рисует Command Code, поэтому запрос разрешения (и любой другой диалог одобрения) в этот список не входит и никогда не войдёт.
  • Ответы на разрешения останавливаются на самом запросе. onPermissionRequest видит только те запросы, которые движок и так собирался вам показать: правила запрета, режим планирования и dont-ask всегда побеждают, а запрос, вынужденный проверкой безопасности, мод может отклонить, но никогда не разрешить. В headless-запуске, где запрос иначе получил бы ответ автоматически, ответ мода тоже используется.
  • Управление сессиями ограничено поверхностью харнесса. cmd.sessions покрывает то, чем владеет живой харнесс (compact, tree, navigate, labels); создание, переключение и форк сессий — это жизненный цикл хоста, а не состояние харнесса, и остаётся за UI хоста.

Интеграторы подключают ту же машинерию напрямую: createModHost + loadMods + createHarness({modHost}) (или createNodeHarnessSession({modHost})) — всё экспортируется из @commandcode/harness.

Обзор справочника ​

Дальше на этой странице идёт полный справочник по порядку:

Справочник ModApi ​

export default function (cmd: ModApi) — фабрика. ModFactory = (cmd: ModApi) => void | Promise<void>. Каждый вызов add* / on / hooks возвращает Disposable { dispose(): void } — .dispose() отменяет ровно эту регистрацию (идемпотентно; второй вызов ничего не делает). Disposable делают регистрацию отзываемой и служат швом, который в будущем переиспользует горячая перезагрузка.

Поля ​

ЧленТипПримечания
cmd.namestringимя мода (из имени файла / манифеста)
cmd.cwdstringкорень рабочего пространства
cmd.sessionModSessionApi | undefinedшов персистентности; undefined, пока не привязан
cmd.eventsModEventBusemit(channel, data?) / on(channel, handler) — кросс-модовая шина
cmd.uiModUinotify / confirm / select / input / setStatus / widget / refreshWidgets / pane / openPane / closePane / toast / copy / override / setTitle / capabilities
cmd.sessionsModSessionControlscompact / tree / leafId / navigateTree / setLabel
cmd.stateModStateApiget / set / subscribe — значения сессии, переживающие горячую перезагрузку
cmd.storeModStoreApiget / set / delete — значения, живущие между сессиями (~/.commandcode/mods/.store/<mod>.json)
cmd.clockModClockApievery / after — таймеры, отменяемые вместе с модом
cmd.modelModModelApicomplete — один промпт на модельном клиенте сессии
cmd.promptModPromptApisubmit — начать собственный ход, когда сессия свободна
cmd.processModProcessApispawn — запустить процесс и передавать его вывод по мере поступления
cmd.audioModAudioApiplay / bell — звуковые файлы, поставляемые с модом, и терминальный звонок

Методы регистрации ​

Вызывайте их, пока выполняется фабрика мода. Каждый возвращает Disposable.

МетодСигнатура
cmd.hooks(hooks: ModHooks) => Disposable
cmd.addTool(tool: ToolModule) => Disposable
cmd.addCommand(command: {name; description?; argumentHint?; handler}) => Disposable
cmd.addFlag(name, {type: 'boolean'|'string'; default?; description?}) => Disposable
cmd.addProvider(module: ProviderModule) => Disposable
cmd.addRenderer(customType: string, (data) => readonly string[]) => Disposable
cmd.addShortcut(keys: string, {description?; handler}) => Disposable
cmd.addSkillsPath(path: string) => Disposable
cmd.on(event: AgentEventType | 'session_start' | 'session_shutdown', handler) => Disposable
cmd.addAgent(definition: {name; description; systemPrompt; tools?; disallowedTools?; model?; maxTurns?}) => Disposable
cmd.expose(name, fn) => Disposable

Что делает каждый из них:

  • cmd.hooks(hooks) — мутирующие хуки жизненного цикла: поверхность AgentMod (transformContext, beforeToolCall, afterToolCall, onTurnStart/End, appendSystemPrompt, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop) плюс transformInput, onSessionStart и onSessionEnd. Несколько вызовов складываются по порядку с семантикой mod-runner (threading, chaining, any-true, later-wins). Полный контракт каждого хука — в разделе контракты хуков.
  • cmd.addTool(toolModule) — ToolModule, который может вызвать модель; политика коллизий та же, что у AgentMod.tools (существующие имена побеждают, коллизия порождает mod_error). run инструмента получает {input, runtime, signal} и возвращает {ok: true, content: [{type: 'text', text}]} или {ok: false, error}. Ставьте readOnly: true, когда инструмент ничего не меняет (тогда он остаётся доступным в режиме планирования).
  • cmd.addCommand({name, description, handler}) — слэш-команда /name. Обработчик возвращает данные, а не колбэки: {prompt} запускает автоматизированный ход, {message} рисует информационную строку, ничего — чистый побочный эффект. Он получает {args, ui, cwd, exec}. При совпадении имён побеждает первая регистрация среди модов.
  • cmd.addFlag(name, {type, default}) / cmd.getFlag(name) — именованные опции; значения приходят из повторяемого CLI-флага --mod-option name=value (в остальных случаях действуют значения по умолчанию).
  • cmd.on(event, handler) — наблюдение за любым AgentEvent плюс события жизненного цикла хоста session_start / session_shutdown (срабатывают, когда харнесс привязывается к хосту и когда переключение или dispose сессии заменяет его). Обработчики изолированы: выброшенное исключение становится событием mod_error {hook: 'on:<event>'}, а не падением.
  • cmd.addProvider(providerModule) — регистрация провайдера модели через тот же шов ProviderModule, который используют встроенные моды (id, транспорт, auth-хуки, список моделей). Хост добавляет провайдеров мода к своему набору — моды расширяют набор провайдеров и никогда его не заменяют.
  • cmd.addRenderer(customType, data => lines) — рендерер для пользовательского типа записи; возвращает строки для печати (оформляйте их ANSI-последовательностями — picocolors, хелперы @commandcode/tui или сырые коды). При совпадении типов побеждает первая регистрация среди модов.
  • cmd.addAgent(definition) — тип субагента, которому модель может делегировать через инструмент task, рядом со встроенными и дисковыми агентами. tools по умолчанию '*'. Имя агента, которое уже существует (встроенное, проектное, пользовательское или из другого мода), пропускается с предупреждением. Значения читаются на лету, поэтому агенты из перезагрузки появятся в следующем ходе.
  • cmd.expose(name, fn) / cmd.use(mod, name) — поделиться функцией с другими модами и вызвать общую функцию другого мода. use отвечает undefined, пока тот мод не загрузился и не выставил имя, поэтому вызывайте её из хука или команды, а не только во время работы фабрики.
  • cmd.addShortcut(keys, {description, handler}) — горячая клавиша в интерактивном TUI. keys — сочетание modifier+key, которое обязано содержать Ctrl или Alt (alt+r, ctrl+r); сочетание без модификатора или только с Shift (p, enter, shift+p) отклоняется с предупреждением, потому что оно также печатало бы в промпт. Отклоняется и сочетание Ctrl+Shift, у которого клавиша доходит до терминала как голый управляющий байт — буквы, цифры, Enter, Tab, Backspace (ctrl+shift+p, ctrl+shift+enter), — потому что Shift до них не доходит (клавиши стрелок и навигации сохраняют модификаторы, поэтому ctrl+shift+up подходит). ctrl+m, ctrl+i, ctrl+h и ctrl+[ тоже отклоняются: терминалы отправляют их как Enter, Tab, Backspace и Escape. Сочетание, которое уже занято встроенным (ctrl+c, ctrl+o, клавиша редактора вроде ctrl+k), отклоняется так же — собственные биндинги хоста срабатывают первыми, так что оно никогда бы не сработало, — как и клавиши, которые промпт обрабатывает сам (alt+w, ctrl+z, ctrl+g, ctrl+s, ctrl+v, alt+v): они остаются занятыми даже при перепривязке. Обработчик получает тот же контекст {args, ui, cwd, exec}, что и обработчик команды, и может вернуть тот же результат {message} / {prompt}. Хост диспетчеризует горячие клавиши модов ПОСЛЕ своих биндингов, поэтому мод может добавлять клавиши, но никогда не затеняет прерывание, выход, переключение режимов или переключатели транскрипта; Esc остаётся за хостом. При совпадении сочетаний побеждает первая регистрация среди модов; /hotkeys перечисляет их в разделе Mods. В headless-хостах горячие клавиши не срабатывают.
  • cmd.addSkillsPath(path) — дополнительное расположение навыков (один каталог навыка или каталог навыков) для обнаружения навыков в этой сессии. Относительные пути разрешаются относительно файла самого мода, так что пакет может указать на поставляемый им ./skills. Собирается во время работы фабрики. Пакет может объявить то же самое без кода, через свой манифест — см. Превращение мода в пакет.

Живые методы ​

Они работают в любой момент после того, как харнесс привязался к хосту модов.

МетодСигнатура
cmd.getFlag(name) => boolean | string | undefined
cmd.showEntry(customType, data?) => void
cmd.queueMessage({content, deliverAs?: 'steer' | 'follow-up'}) => void
cmd.sendUserMessage(content, {deliverAs?}) => void
cmd.exec({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}>
cmd.process.spawn({command, args?, cwd?, signal?, onOutput: (chunk, stream) => void}) => Promise<{code}>
cmd.setSessionName / setModel / setEffort(value) => void (буферизуется до привязки)
cmd.getSessionName / getModel / getEffort() => value | undefined (undefined до привязки)
cmd.getContextUsage() => {tokens, contextWindow, percent} | undefined
cmd.getCommands() => readonly {name, description?, mod}[]
cmd.isIdle() => boolean
cmd.shutdown() => void
cmd.getAllTools / getActiveTools() => readonly string[]
cmd.getToolInfo() => readonly {name, description, readOnly, active}[]
cmd.setActiveTools(names: readonly string[]) => void
cmd.use(mod, name) => fn | undefined
cmd.state.get / set / subscribe(key) => T | undefined / (key, value) => void / (key, listener) => Disposable
cmd.store.get / set / delete(key) => T | undefined / (key, value) => Promise<void> / (key) => Promise<void>
cmd.clock.every / after(ms, callback) => Disposable
cmd.model.complete({prompt, system?, model?, maxOutputTokens?, signal?}) => Promise<ModModelCompleteResult>
cmd.prompt.submit(text) => void
cmd.ui.copy(text) => Promise<ModCopyResult>
cmd.audio.play({file, timeoutMs?}) => Promise<ModAudioResult>
cmd.audio.bell() => ModAudioResult

Подробности:

  • cmd.ui — notify (строка notice в ленте), confirm / select / input (модальное окно вопроса Interaction в TUI; в headless — детерминированные значения по умолчанию: false/undefined), setStatus, widget, refreshWidgets, pane + openPane(id, {focus?}) / closePane, toast, override, setTitle. Полный контракт диалогов, статуса, виджетов и переопределений — в разделе UI-поверхность.
  • cmd.queueMessage({content, deliverAs}) — steer попадает после текущей пачки инструментов (за любыми сообщениями пользователя, стоящими в очереди в том же опросе), follow-up — только когда запуск собирался бы остановиться (раньше подталкиваний к продолжению, но после ввода пользователя из очереди). Сообщения в очереди направляют уже идущий запуск; они никогда его НЕ НАЧИНАЮТ.
  • cmd.sendUserMessage(content, {deliverAs}) — НАСТОЯЩЕЕ сообщение пользователя: отражается в ленте как типизированный ввод и, когда агент свободен, сразу начинает ход. Пока идёт запуск, steer (по умолчанию) ставит его в очередь за активным ходом точно так же, как это сделал бы ввод с клавиатуры, а follow-up держит его до момента, когда запуск собирался бы остановиться. В headless пути отправки нет, поэтому вызов откатывается к queueMessage с тем же deliverAs.
  • cmd.getSessionName() / getModel() / getEffort() — живые значения, стоящие за сеттерами; undefined до привязки сессии.
  • cmd.getContextUsage() — {tokens, contextWindow, percent} для привязанной сессии: tokens и percent равны null, пока первый вызов модели не измерил окно (никогда не обманчивые 0%).
  • cmd.getCommands() — все слэш-команды, зарегистрированные модами на этом хосте ({name, description, mod}), чтобы мод мог найти соседей или избежать занятого имени. Встроенные команды принадлежат хосту и в список не попадают.
  • cmd.isIdle() — false, пока на привязанном харнессе идёт запуск агента (run_start … run_end), иначе true; до привязки тоже true.
  • cmd.shutdown() — просит интерактивный хост выйти, как только завершится текущий ход (тот же путь, что /exit). В headless это no-op: print-запуск заканчивается сам.
  • cmd.getToolInfo() — зарегистрированные инструменты с description, readOnly и active (с учётом фильтра, управляемого модами) — описательный вариант голых имён из getAllTools().
  • cmd.session — шов персистентности ModSessionApi (appendCustomEntry / appendCustomMessageEntry / getCustomEntries), undefined до привязки. См. ModContext.session — поверхность персистентности модов.
  • cmd.showEntry(customType, data) — отрисовать пользовательскую запись в живую ленту через рендерер, зарегистрированный для этого типа (типы без рендерера печатаются как JSON). TUI подключает приёмник; headless-запуски отбрасывают записи. В паре с cmd.session.appendCustomEntry, когда данные должны ещё и сохраняться.
  • cmd.sessions — живые элементы управления сессией: compact(), tree() (дерево ветвей сессии в виде стабильных узлов {id, label, children}), leafId(), navigateTree({targetId, summarize?, customInstructions?}) и setLabel({targetId, label}). Они читают живое состояние харнесса, поэтому, в отличие от буферизующих сеттеров, бросают понятную ошибку, когда сессия ещё не привязана.
  • cmd.exec({command, args}) — запуск процесса через Runtime харнесса (аргументы экранируются для shell).
  • cmd.process.spawn({command, args, onOutput}) — та же командная строка, что у cmd.exec, но каждый кусок stdout/stderr попадает в onOutput(chunk, stream) по мере того, как процесс его выдаёт (stream равен 'stdout' или 'stderr'), так что мод может показывать прогресс, следить за логом или реагировать на строку, не дожидаясь выхода. Резолвится {code}, когда процесс завершается; прерывание по signal убивает дерево процессов и отклоняет промис. Бросивший исключение onOutput становится mod_error {hook: 'on:process'}, а процесс продолжает работать. Вывод для вас не буферизуется: сохраняйте нужное из кусков сами.
  • cmd.getAllTools() / cmd.getActiveTools() / cmd.setActiveTools(names) — фильтр инструментов, управляемый модами: отключённые инструменты исчезают из схем модели и отказываются исполняться.
  • cmd.events — небольшая кросс-модовая шина pub/sub (emit / on).
  • cmd.state — значения, которые хост держит для сессии, с пространством имён на каждый мод. Они переживают горячую перезагрузку (переменные модуля начинаются заново), а set перерисовывает каждый виджет и панель, поэтому отрисовка, читающая состояние, всегда актуальна. subscribe(key, listener) слышит записи этого мода по этому ключу.
  • cmd.store — значения, живущие между сессиями, по одному JSON-файлу на мод. get синхронный (файл читается до запуска фабрики); set и delete пишут сразу.
  • cmd.clock.every(ms, fn) / after(ms, fn) — таймеры, которыми владеет мод: отменяются своим Disposable, а при перезагрузке мода — все сразу. Они никогда не держат процесс открытым, а бросивший исключение колбэк становится mod_error, пока таймер держит своё расписание.
  • cmd.model.complete({prompt}) — одно текстовое дополнение на модельном клиенте сессии, без транскрипта и без инструментов, оплачиваемое и учитываемое как сессия. Резолвится всегда: {isAnswered: true, text, model} или {isAnswered: false, reason: 'unavailable' | 'error' | 'aborted'} (unavailable до привязки сессии).
  • cmd.prompt.submit(text) — отправить промпт как будто его ввели: в TUI он начинает ход, когда сессия свободна, и встаёт в очередь за идущим. В headless он попадает в очередь follow-up.
  • cmd.ui.copy(text) — записать текст в системный буфер обмена тем же хелпером, что использует /copy. См. Буфер обмена и звук.
  • cmd.audio.play({file}) / cmd.audio.bell() — проиграть звуковой файл, поставляемый с модом, или подать терминальный звонок. См. Буфер обмена и звук.

ModHooks: хуки, которые меняют поведение ​

Каждый хук опционален, а хуки из нескольких вызовов cmd.hooks() складываются.

transformContext, appendSystemPrompt, transformSystemPrompt, transformMessage, transformTextDelta, beforeModelRequest, beforeToolCall, afterToolCall, onTurnStart, onTurnEnd, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop, а также хуки уровня хоста transformInput, onPermissionRequest, onSessionStart и onSessionEnd.

  • beforeToolCall возвращает {block?, input?, additionalContext?, terminate?, result?}. result отвечает на вызов, не запуская инструмент.
  • beforeModelRequest возвращает {model?} для одного запроса.
  • transformSystemPrompt возвращает полный системный промпт для отправки.
  • transformMessage возвращает сообщение, которое сохранит транскрипт.
  • transformTextDelta возвращает кусок ответа для показа (синхронно) или '', чтобы его отбросить или придержать.
  • onPermissionRequest возвращает 'allow' | 'deny' | undefined.
  • afterToolCall получает параметр isError (PostToolUse против PostToolUseFailure) и возвращает {content?, isError?, additionalContext?, terminate?, modState?}.
  • onStop получает {state, stopReason, turnNumber, lastAssistantText} и возвращает {continue?, reason?}.
  • transformInput возвращает {action:'continue'} | {action:'transform', text} | {action:'handled', message?}.
  • onSessionStart/onSessionEnd получают {source} / {reason}.

Контракты отдельных хуков, гарантии порядка и политика ошибок — в разделе контракты хуков.

  • transformInput({text}) перехватывает типизированные промпты пользователя до того, как они дойдут до модели (это хук модов UserPromptSubmit): верните {action: 'transform', text}, чтобы переписать (обработчики складываются — следующий видит уже переписанный текст), {action: 'handled', message?}, чтобы полностью поглотить промпт (на его месте рисуется необязательная информационная строка), либо undefined/{action: 'continue'}, чтобы пропустить дальше. Перехватывается только настоящий типизированный ввод — автоматизированные ходы, мета-сообщения, промпты с изображениями и слэш-команды через него не идут; бросивший исключение обработчик пропускается (mod_error {hook: 'transformInput'}), но промпт никогда не теряется. Он живёт в hooks, потому что МЕНЯЕТ то, что видит агент.
  • onStop({state, stopReason, turnNumber, lastAssistantText}) — хук Stop — верните {continue: true, reason?}, чтобы заставить запуск, который иначе бы завершился, продолжать работу (причина едет в автоматизированном ходе). Срабатывает только при естественном завершении, побеждает любой мод, а цикл ограничивает число последовательных продолжений.
  • onPermissionRequest({toolName, input, description, isSafetyPrompt}) отвечает на запрос разрешения до того, как его увидит человек. Он срабатывает только там, где движок и так спросил бы человека, поэтому правила запрета, режим планирования и dont-ask уже решили всё, что покрывают. Спрашивают каждый хук, и побеждает запрет: любой 'deny' отвечает на запрос, а 'allow' держится только если ни один хук не запретил, так что мод, который что-то блокирует, нельзя перебить другим модом, который разрешает. Бросивший исключение хук пропускается (mod_error). На запросе, вынужденном проверкой безопасности (isSafetyPrompt: чувствительная запись, рискованная команда), 'deny' работает, а 'allow' игнорируется, и спрашивают человека.
  • onSessionStart({source}) / onSessionEnd({reason}) — хуки SessionStart / SessionEnd, срабатывающие один раз за сессию: когда хост привязывается к сессии или снимает её (source: 'startup' | 'resume' | 'reload', reason: 'shutdown' | 'replaced' | 'reload'). reload — это горячая перезагрузка самого мода в той же сессии: старый код ею заканчивается, новый ею начинается. Действуйте через захваченный модом cmd; они выполняются в режиме fire-and-forget (исключение становится mod_error), поэтому хук сессии никогда не блокирует привязку или dispose. Чистое наблюдение тоже доступно через cmd.on('session_start' | 'session_shutdown') — эти именованные хуки добавляют типизированные метаданные.

Типы событий on ​

Любой AgentEvent['type'] — включая run_start, run_end, turn_start, turn_end, model_request_start, model_request_end, tool_running, tool_completed, tool_errored, subagent_start, subagent_stop, subagent_progress, compaction_start, compaction_done, notice, session_titled, permission_mode_changed, config_setting_changed, mod_error — плюс два события жизненного цикла хоста session_start / session_shutdown. (subagent_start/subagent_stop — это SubagentStart/Stop; compaction_start/compaction_done — Pre/PostCompact; notice — Notification.) Полный каталог полезных нагрузок — в разделе каталог AgentEvent.

Хуки и события ​

Шов расширения харнесса. Мод — это обычный объект хуков жизненного цикла, который меняет состояние агента или решения цикла. Чистые наблюдатели (UI, телеметрия, логгеры) — не моды: они подписываются на поток AgentEvent. Хуки меняют поведение, подписчики за ним наблюдают. В загружаемом моде cmd.hooks({...}) регистрирует хуки, а cmd.on(event, ...) подписывается на события.

Мод также может добавлять инструменты, и каждый хук получает ModContext ({emit, signal, cwd, session?}) дополнительным последним аргументом, так что мод может поднимать собственные события и сохранять долговечное состояние без лишней обвязки.

Где срабатывает каждый хук ​

Один run() = один ход пользователя → много ходов модели («раундов»). Цикл ниже — это цикл агента, дословно по порядку; хуки модов помечены ◆, события — →.

text
run({state, userInput?, config})
  fork AbortController; working := state (+ user message when userInput given)
  ctx := {emit, signal, cwd, session} - built ONCE per run,
       passed as the LAST argument to every ◆ hook below
  → run_start {sessionId}
  poll getSteeringMessages once (catch pre-run queued input)
  loop:
    abort check ──────────────────────────────────► stop: interrupted (→ interrupted)
    ◆ onTurnStart (each mod, in order; returns new state)
    → turn_start {turnNumber}
    read live permission mode ONCE - pinned for this round
    ◆ beforeModelRequest (each mod; later wins) → the model for THIS request; the
      deferred-tools prompt and tool list below are built for it
    resolve systemPrompt (string | builder({sessionId, state, permissionMode}))
    ◆ appendSystemPrompt (each mod; non-empty returns joined with '\n\n', in
      registration order, and appended AFTER the base prompt)
    ◆ transformSystemPrompt (each mod; the FULL prompt threaded mod→mod)
    ◆ transformContext (each mod; messages threaded mod→mod; result is EPHEMERAL -
      used for this call only, never written back to state.messages)
    prepareForSend (core wire-validity pass: orphan heal, merge, strip meta)
    → message_start
    → model_request_start {model}
      modelClient.complete(...) (streams → text_delta / thinking_* /
        message_update / continuation_recovery /
        tool_input_coerced; retry → api_retry)
    ◆ transformTextDelta on every streamed text chunk (each mod, threaded;
      sync), BEFORE its → text_delta; a retry starts the chunk log over
    → model_request_end {model, usage, stopReason}
    ◆ transformTextDelta {delta: '', done: true} (flush; output → text_delta)
    assistant text := the rewritten chunks, joined, per text block
    → message_end {content} (the rewritten content)
    ◆ transformMessage on every message before it is committed
      (user input, assistant reply, tool results, steering, follow-ups)
    working += assistant message (provider-executed blocks filtered out)
    if client tool calls:
      runTools(...) (see tool-dispatch order below)
      working += tool-results user message
      working := setModState(...) for every afterToolCall `modState`
        contribution across the batch (BEFORE onTurnEnd runs)
      any deny ────────────────────────► pendingStop: permission_denied
      any terminate ───────────────────► pendingStop: terminate
      else poll getSteeringMessages → append (source 'steering')
    ◆ onTurnEnd (each mod; receives THIS turn's usage; returns new state)
    → turn_end {turnNumber, hadToolCalls, usage}
    onCommit(working) ← the durability boundary
    pendingStop? ──────────────────────► stop
    ◆ shouldStopAfterTurn (any true) ──► stop: stop_hook
    turnNumber ≥ maxTurns ─────────────► stop: max_turns
    if no tool calls:
      no follow-up wanted ─────────────► ◆ onStop (any continue → keep going)
      else stop: end_turn
    ◆ prepareNextTurn (merged; later mods win) → may swap model / effort
    ◆ onRunEnd (each mod, awaited)
    → run_end {result}

Порядок диспетчеризации инструментов ​

text
for each tool_use: → tool_queued {toolCallId, toolName, input} ← ALWAYS the original input
  phase 1 - permissions (always sequential):
    permissions.generateDescription (5s timeout → null)
    permissions.check({toolName, input, description, permissionMode})
      throw ⇒ deny (fail closed)
    deny → tool_denied; batch aborted: every call gets a tool_result,
      run stops with permission_denied AFTER the turn commits
  phase 2 - execution (sequential, or parallel when config.toolExecution='parallel'):
    ◆ beforeToolCall (each mod, in order; `input` rewrites CHAIN mod→mod;
      execution uses the LAST mod's input; a mod may also set `terminate`)
    block ⇒ → tool_hook_blocked {hookOutput}; the block reason becomes the
      tool_result text; NO tool_running / tool_completed / tool_errored fires
    result ⇒ the tool does NOT run; the answer is its (successful) output
    → tool_running {toolCallId, toolName, description}
    toolRunner.execute({toolName, input: <possibly rewritten>, signal, onUpdate})
    ◆ afterToolCall (each mod, in order; sees current content, may replace it,
      append `additionalContext`, flip `isError`, set `terminate`, or persist
      `modState`)
    beforeToolCall additionalContext strings appended to the final content
    → tool_completed {result} (or → tool_errored {error})

Гарантии порядка, на которые могут опираться авторы модов ​

  • Каждый хук получает ModContext ({emit, signal, cwd, session?}) своим ПОСЛЕДНИМ аргументом. Он объявлен опциональным в каждой сигнатуре хука, поэтому хук, который объявляет только объект параметров, продолжает компилироваться и работать без изменений; ядро всегда передаёт реальный объект во время выполнения.
  • beforeToolCall завершается (для каждого мода) до того, как испускается tool_running. Перезаписи его input складываются по модам, но событие tool_queued было испущено с ИСХОДНЫМ вводом ещё до запуска любого хука и никогда не испускается повторно — отображение всегда показывает, что попросила модель, и только исполнение видит перезапись.
  • afterToolCall завершается до того, как испускается tool_completed / tool_errored, — терминальное событие несёт содержимое после хука, а его isError (когда задан) решает, какое из двух испустить, независимо от того, бросило ли исполнение исключение.
  • modState из afterToolCall фиксируется сразу после того, как добавлено сообщение с результатами инструментов пачки, — ДО запуска onTurnEnd, поэтому onTurnEnd его видит.
  • onTurnStart предшествует turn_start; onTurnEnd предшествует turn_end и onCommit.
  • onCommit срабатывает один раз за завершённый ход с полным сериализуемым состоянием.
  • shouldStopAfterTurn вычисляется после фиксации хода, поэтому остановка по stop_hook никогда не теряет ход, который её вызвал.
  • prepareNextTurn срабатывает только когда цикл действительно продолжается (никогда на останавливающемся ходе).
  • onRunEnd ожидается до события run_end — это место для работы, которая обязана завершиться (сброс на диск, запуск обучения); подписчики событий для этого быть не должны.
  • Моды выполняются в порядке регистрации на каждой фазе; встроенные идут первыми, вызывающие моды — последними (они видят контекст после компакции и результаты инструментов после хуков).
  • Хуки никогда не бросают исключение наружу: раннер перехватывает его на каждый мод и каждый хук. Упавший transformContext оставляет сообщения без изменений; упавший onTurnStart/onTurnEnd сохраняет предыдущее состояние; упавший shouldStopAfterTurn означает «не останавливаться»; упавшие хуки инструментов пропускаются. Каждое место перехвата ДОПОЛНИТЕЛЬНО испускает событие mod_error {modId, hook, error} и только потом откатывается к своему безопасному значению по умолчанию — решение цикла не меняется, но деградация никогда не бывает молчаливой.

Контракты хуков ​

transformContext({messages, state, signal?}, ctx?) → messages ​

Срабатывает один раз за раунд, после appendSystemPrompt, перед prepareForSend. Сообщения проходят через каждый мод по порядку — каждый получает вывод предыдущего мода. Результат используется только для этого вызова модели и никогда не записывается обратно в state.messages: долговечный лог не затрагивается (компакция на это опирается — записанный транскрипт остаётся полным). Верните входной массив без изменений (ту же ссылку), чтобы обозначить «без изменений».

shouldStopAfterTurn({state, turnNumber}, ctx?) → boolean ​

Срабатывает после фиксации хода. Любой мод, вернувший true, завершает запуск с stopReason: 'stop_hook' — первый true обрывает вычисление. Это ранняя остановка (бюджет цели исчерпан); она противоположна хуку Stop, заставляющему продолжать.

prepareNextTurn({state, turnNumber}, ctx?) → {model?, effort?} | undefined ​

Срабатывает в конце продолжающегося раунда. Результаты сливаются по модам — более поздние моды переопределяют более ранние по каждому полю. Возвращённые model/effort применяются начиная со следующего вызова модели (config никогда не мутируется). effort трёхзначен: ThinkingEffort задаёт его, null сбрасывает к собственному значению входящей модели, а отсутствие поля оставляет текущий effort без изменений. Передавайте null (а не отсутствующее поле), когда переключаетесь на модель, которая должна работать со своим значением по умолчанию, — иначе запуск сохранит effort исходящей модели.

appendSystemPrompt({state}, ctx?) → string | undefined ​

Срабатывает один раз за раунд, сразу после того, как разрешился базовый systemPrompt раунда, и до transformContext. Непустой результат каждого мода соединяется через '\n\n' в порядке регистрации и добавляется ПОСЛЕ базового промпта. Может вернуть обычную строку ИЛИ Promise. Должен быть побайтово стабильным между раундами при тех же долговечных входных данных — кэш префикса промпта у провайдера опирается на байты системного промпта, поэтому значение, которое меняется от хода к ходу без соответствующего изменения state/modState, ломает кэш каждый раунд. Считайте один раз, сохраните в modState, читайте обратно.

beforeToolCall({toolCallId, toolName, input, state}, ctx?) → {block?, additionalContext?, input?, terminate?} | undefined ​

Срабатывает на каждый вызов инструмента, после того как проверка разрешений пройдена, и перед исполнением.

  • block: true — инструмент не запускается. Ядро испускает tool_hook_blocked с hookOutput = ваш additionalContext (или 'Blocked by a pre-tool hook.'), и этот же текст становится tool_result, который видит модель. beforeToolCall последующих модов для этого вызова не запускается. Для заблокированного вызова не испускается ни tool_running/tool_completed, ни tool_errored.
  • additionalContext (без block) — собирается по модам и добавляется отдельными текстовыми блоками к tool_result после исполнения и после переопределений afterToolCall.
  • input — переписывает ввод, с которым инструмент реально исполняется. Складывается по модам: следующий мод получает в параметре input ВАШУ перезапись, а не исходную.
  • terminate: true — останавливает запуск после завершения этой пачки инструментов. Семантика ANY (достаточно одного хука, на одном вызове, в одной пачке). Комбинируется с block в одном возвращаемом значении.
  • undefined — мнения нет.
  • result — ответить на вызов, НЕ запуская инструмент: это содержимое становится успешным выводом инструмента (в отличие от block, который является ошибкой). beforeToolCall последующих модов для этого вызова не запускается; afterToolCall запускается, поэтому пост-хук видит ответ как настоящий результат. Если заданы оба, побеждает block.

beforeModelRequest({state, turnNumber, model}, ctx?) → {model?} | undefined ​

Срабатывает один раз за запрос к модели, в начале раунда, до того как построены промпт, контекст и инструменты раунда. Список инструментов и промпт отложенных инструментов строятся затем для той модели, к которой идёт запрос, поэтому модель, которая не умеет загружать отложенные инструменты, получает все инструменты сразу — так же, как при выборе её через /model. Возвращённая model отправляет только этот запрос к ней; следующий запрос снова идёт к модели запуска (чтобы переключиться до конца запуска, используйте prepareNextTurn). Побеждает более поздний мод. Переключённый запрос теряет явный BYOK-маршрут запуска, который принадлежит модели запуска. model_request_start / model_request_end сообщают модель, к которой запрос реально ушёл.

Остальные хуки раунда ещё не сработали, поэтому значение, сохранённое одним из них, относится к предыдущему раунду. Чтобы маршрутизировать по размеру контекста, измеряйте state.messages внутри самого beforeModelRequest.

transformSystemPrompt({system, state}, ctx?) → string ​

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

transformMessage({message, state}, ctx?) → AgentMessage ​

Срабатывает для каждого сообщения, которое фиксирует цикл: ввод человека, каждый ответ модели, каждая пачка результатов инструментов, сообщения steering и follow-up. Возвращённое сообщение — это то, что сохраняет транскрипт и то, что отправляет каждый последующий запрос, поэтому именно здесь место для вычищения секретов и маскирования персональных данных. Складывается по модам. Роль и идентификаторы каждого tool_use / tool_result должны сохраниться: перезапись, меняющая их, отклоняется с mod_error {hook: 'transformMessage'}, и предыдущее сообщение остаётся. События живого потока (text_delta, message_end) по-прежнему показывают сырой вывод модели; транскрипт хранит перезапись. Чтобы изменить и показываемое, используйте transformTextDelta.

transformTextDelta({delta, done, turnNumber, attempt, state}, ctx?) → string ​

Срабатывает для каждого куска текста ответа модели по мере его потока, до того, как для него испускается событие text_delta. Верните кусок для показа (переписанный или как есть) либо '', чтобы его отбросить. Складывается по модам; отброшенный кусок останавливается на этом месте, поэтому последующие моды его не видят. То, что показывает лента, и то, что хранит транскрипт, — один и тот же текст: зафиксированное сообщение ассистента (и message_end, и живой message_update) несёт ровно переписанные куски, соединённые и разложенные по тем текстовым блокам, в которые они стримились. Рассуждения и вызовы инструментов не затрагиваются.

  • Синхронно и быстро. Хук работает внутри потока модели. Хук, который бросает исключение, возвращает промис или не строку, пропускается для этого куска с mod_error {hook: 'transformTextDelta'}, а кусок проходит дальше в том виде, в каком его отдал предыдущий мод.
  • Финальный сброс. Когда ответ завершён, каждый мод вызывается ещё раз с {delta: '', done: true}. Верните всё, что придержали (начало ключа, разорванного между двумя кусками), и это поедет последним text_delta и окажется в конце последнего текстового блока.
  • Повторы. attempt считает попытки этого запроса с 0. Повтор посреди потока выбрасывает куски брошенной попытки (лента помечает шов через stream_restart), поэтому мод, который копит состояние между кусками, должен начинать заново, когда меняется turnNumber или attempt.
  • Клиенты без стриминга. Ответ, пришедший без текстовых дельт, всё равно проходит через хук — по одному куску на текстовый блок, без выдуманных событий text_delta.
  • Срабатывает до transformMessage, который на фиксации всё равно видит (переписанное) сообщение ассистента.

afterToolCall({toolCallId, toolName, input, result, isError, state}, ctx?) → {content?, terminate?, additionalContext?, isError?, modState?} | undefined ​

Срабатывает на каждый вызов инструмента после исполнения (в том числе после ошибки исполнения — тогда result содержит текст ошибки). input — ФИНАЛЬНЫЙ ввод, с которым инструмент реально работал (после всех перезаписей beforeToolCall).

  • isError (параметр) — упало ли САМО исполнение инструмента; читается до любого переопределения хуком и складывается по модам как result. Это и есть различие PostToolUse против PostToolUseFailure: хук может ветвиться по isError, чтобы реагировать только на сбои. Это ВХОДНОЙ сигнал; возвращаемое поле isError ниже — ВЫХОДНОЕ переопределение, выбирающее терминальное событие.
  • content заменяет результат инструмента, который увидит модель (складывается mod→mod).
  • terminate: true завершает запуск с stopReason: 'terminate' после того, как завершится вся пачка.
  • additionalContext — добавляется ОТДЕЛЬНЫМ текстовым блоком после content (и после любых строк additionalContext из beforeToolCall).
  • isError — переопределяет, будет терминальным событием tool_completed или tool_errored, независимо от того, бросило ли исполнение исключение.
  • modState — Record<string, unknown>, который сливается в state.modState[mod.id] сразу после фиксации сообщения с результатами инструментов: полная замена слота этого мода, ровно как у setModState. Долговечный канал состояния для хуков инструментов: ни один хук инструмента не может вернуть целый AgentState, как это могут onTurnStart/onTurnEnd.

onTurnStart({state, turnNumber}, ctx?) → AgentState / onTurnEnd({state, turnNumber, hadToolCalls, usage}, ctx?) → AgentState ​

Единственные хуки, которые могут безусловно сохранять изменения состояния: они возвращают новый AgentState (обычно через setModState), который цикл протаскивает дальше и фиксирует. usage в onTurnEnd — расход токенов этого хода. Оба срабатывают каждый раунд, включая раунд, который останавливается.

onRunEnd({state, result}, ctx?) → void ​

Срабатывает один раз, с ожиданием, после выхода из цикла и до события run_end. state — финальное состояние; result несёт finalText, stopReason, turnCount и накопленный usage. Результат изменить нельзя. Это хук «работы, которая обязана завершиться» (обучение, сброс на диск).

onStop({state, stopReason, turnNumber, lastAssistantText}, ctx?) → {continue?, reason?} | undefined ​

Хук Stop — канал принудительного продолжения у модов. Срабатывает, когда ход ЗАВЕРШИЛ бы запуск естественным образом: модель не вернула вызовов инструментов и ни один провайдер продолжений не хочет продолжать. Возврат {continue: true} держит запуск живым: reason (или нейтральное значение по умолчанию) добавляется как автоматизированный ход пользователя с source: 'stop_hook', так что модель узнаёт, почему должна продолжать работу.

  • Побеждает ЛЮБОЙ мод, вернувший continue (первый побеждает, вычисление обрывается).
  • НЕ срабатывает при жёстких остановках (max_turns, terminate, permission_denied, interrupted).
  • Провайдер продолжений (например, continuation nudger) высказывается первым; к onStop обращаются, только когда провайдер отказался.
  • Цикл ограничивает число последовательных продолжений по хуку Stop на 8 — хук, который всегда говорит continue, не может зациклиться навсегда.
  • Отличается от shouldStopAfterTurn (принудительная РАННЯЯ остановка) и onRunEnd (наблюдение за остановкой): только onStop может толкнуть завершённый запуск дальше.
ts
const persistUntilTestsPass = {
  id: 'until-green',
  onStop: async ({ lastAssistantText }) => {
    if (/all tests pass/i.test(lastAssistantText)) return { continue: false }
    return { continue: true, reason: 'Tests are not green yet - keep going.' }
  }
}

ModContext.session: сохранённые данные мода ​

Файловое хранилище сессий в древовидном формате даёт модам долговечный шов на каждую запись к тому же append-only файлу, в котором живёт транскрипт. Доступно в загружаемом моде как cmd.session, а внутри хуков — как ctx.session. Есть два вида записей:

  • appendCustomEntry({customType, data?}) — запись дерева custom. Данные, приватные для мода; никогда не отправляются в LLM и не отрисовываются. Используйте для долговечного учёта, который мод хочет сохранить при возобновлении (счётчики, курсоры, закэшированные решения).
  • appendCustomMessageEntry({customType, content, display, details?}) — запись дерева custom_message. Содержимое, которое модель ДОЛЖНА видеть: оно проецируется как обычное сообщение user в СЛЕДУЮЩЕМ ходе (display: true ещё и рисует его в TUI с особым оформлением; display: false — только контекст). Вызов возвращает {entryId, message} — мод ОБЯЗАН свернуть message в тот AgentState, который возвращает из своего хука, чтобы модель увидела его в этом ходе.
  • getCustomEntries({customType}) — читает обратно все записи custom, которые написал сам этот мод (с фильтром по customType), в порядке файла, по полному списку записей АКТИВНОЙ ветви. Стандартный шаблон перезагрузки: мод с состоянием в памяти засевает его из getCustomEntries при первом onTurnStart.
ts
const MOD_ID = 'turn-counter'
const CUSTOM_TYPE = 'turn-counter/count'

const mod = {
  id: MOD_ID,
  onTurnStart: async ({ state }, ctx) => {
    if (!ctx?.session) return state // a bare unit-test config without a store
    const priorCount = ctx.session.getCustomEntries({
      customType: CUSTOM_TYPE
    }).length
    ctx.session.appendCustomEntry({ customType: CUSTOM_TYPE, data: { count: priorCount + 1 } })
    return state
  }
}

session отсутствует (а не просто пуст) только у голой конфигурации юнит-теста, собранной без долговечного хранилища, — каждый хук должен считать ctx.session потенциально undefined и аккуратно ничего не делать. Запуск с --no-session ВСЁ РАВНО заполняет session: записи добавляются и читаются всё время жизни процесса, просто никогда не попадают на диск.

Соглашения modState ​

AgentState.modState — это Readonly<Record<string, unknown>>: один слот на мод, с ключом по id мода:

ts
const value = getModState<MyShape>({state, modId: 'my-mod'}); // undefined when unset
const next = setModState({state, modId: 'my-mod', value: {...}}); // fresh AgentState
  • Пространство имён — по соглашению: пишите только в слот своего id.
  • Значения должны быть сериализуемыми в JSON — modState сохраняется вместе с сессией и переживает возобновление. Данные уровня запуска и волатильные данные (бюджеты, блокировки, промисы в полёте) живут в замыкании фабрики мода.
  • onTurnStart/onTurnEnd возвращают целый AgentState (обычно через setModState). afterToolCall тоже может сохранять состояние через своё возвращаемое поле modState. У beforeToolCall канала состояния нет; before-хук, которому нужно накапливать данные, держит их в замыкании и сбрасывает через modState в afterToolCall или в onTurnEnd.
  • Пользовательские данные никогда не попадают в state.messages: объединение сообщений — это закрытые wire-типы. Когда моду нужно что-то положить перед моделью, он испускает настоящие сообщения через transformContext (или appendCustomMessageEntry).

Каталог AgentEvent ​

Один синхронный приёмник, разветвление через createEventBus. Полезные нагрузки — снимки, никогда не живые ссылки.

СобытиеКогда срабатываетГлавное в нагрузке
run_start / run_endграницы запускаsessionId / result
turn_start / turn_endграницы раунда (после onTurnStart / onTurnEnd)turnNumber; в конце добавляются hadToolCalls и usage этого хода
message_startперед каждым вызовом модели-
text_delta, thinking_start/delta/endстримингдельты; thinking_end несёт полный текст
message_updateстриминг (накопление в ModelClient)всё частичное сообщение ассистента, включая частичный JSON инструментов
message_endответ завершёнполное содержимое ассистента
model_request_start/endобрамляют вызов инференсаmodel; в конце добавляются usage и сырой stopReason
tool_queuedна каждый вызов, до проверок разрешенийinput — ИСХОДНЫЙ ввод, никогда не переписанный
tool_deniedразрешение отклонено- (пачка затем прерывается)
tool_hook_blockedbeforeToolCall мода заблокировал вызовhookOutput — терминальное для этого вызова: за ним не следует tool_running/completed/errored
tool_runningначинается исполнение (после beforeToolCall)description из permissions.generateDescription
tool_updateстриминг прогресса инструментасодержимое partial
tool_completed / tool_erroredпосле afterToolCallresult / текст error после хука; isError из afterToolCall может выбрать, какое из двух испустить
tool_hooksиспускается модом пользовательских хуков, до терминального события инструмента в этой фазеphase: 'pre' | 'post', lines, outcome
subagent_start / subagent_stopинструмент agent обрамляет вложенный запуск субагентаtoolCallId, subagentType; в конце добавляется tokensUsed
subagent_progressна каждый вызов инструмента внутри работающего субагентаtoolCallId, subagentType, toolName, toolInput, tokensUsed
api_retryцикл повторов, после 3 молчаливых попытокattempt, error, delayMs
compaction_start / compaction_doneмод компакцииtokensSaved (в конце, только когда > 0)
noticeинформация или предупреждение для пользователяlevel, message
skill_loadedнавык активирован явным вызовом пользователя /namename
session_titledавтоматически сгенерированное имя сессии сохраненоtitle
permission_mode_changedдействующий режим разрешений изменился (shift+tab, /plan, инструменты входа и выхода из режима планирования)mode
config_setting_changedнастройка изменена через инструмент configsetting, value, previousValue?
continuation_recoveryход был автоматически продолжен (pause/empty/length/intent)kind, attempt, maxAttempts
tool_input_coercedModelClient спас некорректный (массив/null) ввод инструментаrawType, recovered
tool_input_repairedслой починки переписал ввод инструмента до исполнения (срабатывает независимо от того, запустился ли вызов)rulesFired, hintCount, receivedKeys, repaired
mod_errorхук мода бросил исключение (на любой фазе) или инструмент мода столкнулся с существующим инструментомmodId, hook, error
interruptedзамечено прерывание-
run_errorнеповторяемый сбойсырой Error

Пример: мод квоты записи ​

Блокирует запись вне белого списка, долговечно считает активность инструментов в modState и отчитывается в конце запуска. Задействует канал блокировки, разделение замыкания и modState, а также onRunEnd.

ts
import type { AgentMod, AgentState } from '@commandcode/harness'
import { getModState, setModState } from '@commandcode/harness'

const MOD_ID = 'write-quota'

interface WriteQuotaState {
  readonly writesThisSession: number // durable - survives resume
}

export function createWriteQuotaMod(options: {
  readonly allowedRoot: string
  readonly maxWrites: number
  readonly report: (summary: string) => void
}): AgentMod {
  // Run-scoped tally lives in the closure; flushed into modState at turn end
  // (beforeToolCall cannot return state - see modState conventions).
  let writesThisTurn = 0

  return {
    id: MOD_ID,
    beforeToolCall: async ({ toolName, input, state }) => {
      if (toolName !== 'write_file' && toolName !== 'edit_file') return undefined

      const path = typeof input.file_path === 'string' ? input.file_path : ''
      if (!path.startsWith(options.allowedRoot)) {
        // Block: this text becomes the tool_result the model sees, and the
        // core emits tool_hook_blocked. The run continues - the model adapts.
        return {
          block: true,
          additionalContext: `Writes outside ${options.allowedRoot} are not allowed.`
        }
      }

      const durable = getModState<WriteQuotaState>({ state, modId: MOD_ID })
      if ((durable?.writesThisSession ?? 0) + writesThisTurn >= options.maxWrites) {
        return { block: true, additionalContext: 'Write quota exhausted for this session.' }
      }

      writesThisTurn += 1
      return undefined
    },
    onTurnEnd: async ({ state }) => {
      if (writesThisTurn === 0) return state

      const durable = getModState<WriteQuotaState>({ state, modId: MOD_ID })
      const next: AgentState = setModState({
        state,
        modId: MOD_ID,
        value: { writesThisSession: (durable?.writesThisSession ?? 0) + writesThisTurn }
      })
      writesThisTurn = 0
      return next // committed via onCommit at the turn boundary
    },
    onRunEnd: async ({ state, result }) => {
      const durable = getModState<WriteQuotaState>({ state, modId: MOD_ID })
      options.report(
        `run stopped (${result.stopReason}) after ${result.turnCount} turns; ` +
          `${durable?.writesThisSession ?? 0} writes used`
      )
    }
  }
}

Подключите его как загружаемый мод (cmd.hooks({...}) с теми же обработчиками) — или, если встраиваете харнесс, createHarness({..., mods: [createWriteQuotaMod({...})]}).

UI-поверхность ​

Всё, что мод может вывести на экран, работает через cmd.ui, cmd.addRenderer и cmd.showEntry. Всё это данные, а не компоненты: мод возвращает строки или небольшое дерево элементов, TUI и десктопное приложение их рисуют, headless-запуски деградируют до детерминированных значений по умолчанию, а падающая отрисовка становится однострочным предупреждением — но никогда сломанным экраном.

Уведомления и диалоги ​

  • cmd.ui.notify(message, level?) — строка notice в ленте, оформленная по level: 'info' (по умолчанию) — тусклый маркер, 'warning' — янтарный ⚠, 'error' — красный ⚠. Рисуется во время хода (проектор ленты по ходам); уведомление, поднятое во время работы фабрики, сбрасывается, когда сессия привязывается.
  • cmd.ui.toast(text, {level?, durationMs?}) — короткоживущая строка над промптом, которая сама исчезает (по умолчанию через 4 с). level — info, success, warning или error. Там, где тосты никуда не рисуются (headless), вызов откатывается к строке notice, чтобы сообщение не потерялось.
  • cmd.ui.confirm({title, message?}) / cmd.ui.select({title, options}) / cmd.ui.input({title, placeholder?}) — модальное окно вопроса Interaction в TUI. В headless каждый вызов резолвится своим детерминированным значением по умолчанию: confirm → false, select/input → undefined — автоматического подтверждения не бывает.
  • cmd.ui.setTitle(text | null) — заголовок вкладки терминала. Текст заменяет имя сессии на вкладке (и переживает повторные установки заголовка самим хостом), пока null не вернёт вкладку хосту. В headless — no-op.

Буфер обмена и звук ​

  • cmd.ui.copy(text) — записать text в системный буфер обмена тем же хелпером, что использует /copy. Никогда не бросает исключение: {isCopied: true} либо {isCopied: false, reason}, где reason — headless (нет интерактивного хоста), no-clipboard (буфер обмена машины недоступен, например по SSH без X11) или error.
  • cmd.audio.play({file, timeoutMs?}) — проиграть звуковой файл платформенным плеером: afplay на macOS, paplay, затем aplay на Linux, PowerShell Media.SoundPlayer на Windows. Относительный file разрешается относительно каталога файла самого мода, поэтому кладите звук рядом с ним; абсолютный путь тоже работает. Проигрывание идёт через shell runtime и убивается по истечении timeoutMs (по умолчанию 10 с). Никогда не бросает исключение: {isPlayed: true} либо {isPlayed: false, reason}, где reason — headless, not-found, no-player, timeout или error.
  • cmd.audio.bell() — подать терминальный звонок. Возвращает {isPlayed: true} или {isPlayed: false, reason: 'headless'}.
  • Где это работает сейчас. В десктопном приложении копирование, звонок и проигрывание пока отвечают headless. По SSH без дисплея копирование отвечает no-clipboard. На Windows и на Linux только с ALSA (aplay, без PulseAudio или PipeWire) проигрывание умеет только WAV-файлы. Поставляйте .wav, если мод должен звучать везде.
  • Плохие аргументы тоже не бросают исключений. file, который не строка, отвечает error. timeoutMs, который не положительное целое, откатывается к значению по умолчанию в 10 с. Горячая перезагрузка останавливает любой звук, запущенный старым кодом.

Звук — побочный эффект, поэтому не ждите его через await в хуке, если ход не должен его дожидаться:

ts
cmd.hooks({
  onStop: () => {
    void cmd.audio.play({ file: './done.wav' })
    return undefined
  }
})

cmd.addCommand({
  name: 'copy-branch',
  handler: async ({ exec, ui }) => {
    const { stdout } = await exec({ command: 'git', args: ['branch', '--show-current'] })
    const copied = await ui.copy(stdout.trim())
    return { message: copied.isCopied ? 'Copied.' : `Not copied: ${copied.reason}` }
  }
})

cmd.ui.setStatus(text | null) — постоянный сегмент мода в строке статуса TUI (под вводом). Один сегмент на мод: новый вызов заменяет текст, null (или текст из одних пробелов) очищает его, и возвращённый Disposable тоже очищает — но только пока всё ещё владеет сегментом, поэтому устаревший дескриптор, освобождённый поздно, не может стереть более новый статус. Сегменты из нескольких модов рисуются после встроенных элементов строки в порядке загрузки, а вызов из асинхронного кода перерисовывает её сам. Оформляйте текст ANSI-цветами (SGR) и ссылками OSC 8; всё остальное экранирование — перемещения курсора, очистка экрана, заголовки OSC и запись в буфер обмена — вырезается, а переводы строк и табуляции становятся пробелами. Сегменты модов делят с пользователем одну строку статуса: после встроенных элементов и до прижатой вправо фазы модели; строка обрезается символом … по краю терминала, поэтому последние сегменты могут быть обрезаны. /statusline off скрывает всю строку (при этом cmd.ui.capabilities.status остаётся true). В headless: сохраняется, но нигде не рисуется. Полный пример, собранный по запросу к Command Code, — в разделе Добавьте свои элементы с помощью модов.

ts
cmd.hooks({
  onSessionStart: () => cmd.ui.setStatus('#channel ✓ connected'),
  onSessionEnd: () => cmd.ui.setStatus(null)
})

cmd.ui.capabilities.status / cmd.ui.capabilities.surface — рисует ли этот хост сегменты футера, а также виджеты, панели и тосты (true в интерактивном TUI и десктопном приложении, false в headless). Используйте их, чтобы выбрать поверхность, а не поставлять постоянный фолбэк:

ts
if (cmd.ui.capabilities.status) cmd.ui.setStatus(text)
else cmd.ui.notify(text)

Элементы ​

render({width}) виджета или панели возвращает список узлов. Узел — обычная строка (одна строка) или один из следующих:

ЭлементФормаЧто рисует
text{type: 'text', text, color?, bold?, dim?}стилизованный текст; color — red, green, yellow, blue, magenta, cyan, white, gray
row{type: 'row', children}дочерние элементы в одну строку через пробел
box{type: 'box', title?, children}необязательный жирный заголовок, затем каждый дочерний элемент с новой строки
button{type: 'button', label, hotkey?, key?, onPress}[ r: Reset ]; hotkey — одна буква или цифра, key — её идентичность (по умолчанию равен label)
progress{type: 'progress', value, width?, label?}label ██████░░░░ 60%, value от 0 до 1
divider{type: 'divider'}линия на всю width
input{type: 'input', key, placeholder?, value?, onSubmit(text)}однострочное текстовое поле [ value ]; редактируется, только пока его панель в фокусе, Enter отправляет
select{type: 'select', key, options: [{label, value}], value?, onSelect(value)}○ Small ◉ Medium ○ Large с отмеченным value; в фокусе ←/→ двигают подсветку, Enter выбирает
markdown{type: 'markdown', text}markdown, нарисованный так же, как лента рисует ответы (заголовки, списки, код, таблицы); хост без markdown-рендерера откатывается к простым строкам

width — число колонок, в которые рисует поверхность, так что мод может сам рассчитать свою разметку. Оформление всё равно за модом: обычная строка печатается как написана, включая ANSI-цвета и стили. Прочие escape-последовательности (перемещения курсора, очистка экрана) и управляющие байты, кроме перевода строки и табуляции, вырезаются из любого текста мода, поэтому мод никогда не сможет завладеть терминалом.

input и select требуют key (их идентичность для фокуса и обработчиков); key кнопки по умолчанию равен её label. Их обработчики выполняются в моде как хук: исключение становится mod_error {hook: 'ui.submit'} или {hook: 'ui.select'}, а поверхность перерисовывается после каждой отправки или выбора. Храните то, что они задают, в cmd.state и читайте обратно как value, чтобы поле показывало это после перерисовки или горячей перезагрузки. Изображения и растровая графика элементами не являются: протоколы терминальной графики (kitty, iTerm2, sixel) различаются по терминалам и не переживают построчную перерисовку TUI.

Виджеты: полоса над промптом ​

cmd.ui.widget({placement: 'above-editor' | 'below-editor', render}) — строки, рисуемые вокруг панели ввода; above-editor — полоса прямо над промптом, below-editor — под ним вместе со строкой статуса. render выполняется заново на каждой перерисовке; вызывайте cmd.ui.refreshWidgets(), когда изменились данные, которые он читает (или держите эти данные в cmd.state, чей set перерисовывает сам). Падающий render показывает однострочное предупреждение и сообщает mod_error, чтобы соседние виджеты продолжали рисоваться. Возвращённый Disposable удаляет виджет. В headless: сохраняется, но нигде не рисуется.

ts
let failing = 0

cmd.ui.widget({
  placement: 'above-editor',
  render: () => [
    failing > 0
      ? { type: 'text', text: `✗ ${failing} tests failing`, color: 'red' }
      : { type: 'text', text: '✓ tests green', color: 'green' }
  ]
})

cmd.on('tool_completed', () => {
  failing = readFailingCount()
  cmd.ui.refreshWidgets()
})

Панели ​

cmd.ui.pane({id, title, render}) — панель с заголовком и рамкой над промптом, которую человек открывает и закрывает. Регистрация её не открывает: это делает cmd.ui.openPane(id) (обычно из команды), закрывает cmd.ui.closePane(id), а также /mods open <pane> / /mods close <pane>. Пока панель открыта, Alt+<hotkey> нажимает её кнопки (последняя строка панели их перечисляет), а Alt+0 закрывает новейшую; ввод по-прежнему уходит в промпт. Горячие клавиши, которыми уже владеет промпт (p, v, w, u, m), рисуются, но по этому сочетанию никогда не нажимаются. id панели — <mod>/<id>, поэтому у двух модов может быть своя панель stats; открытость панели переживает горячую перезагрузку.

Закрепление на широких терминалах. Начиная с 140 колонок открытые панели рисуются в колонке справа от промпта (около 40% ширины, от 40 до 72 колонок), а не стопкой над ним, и промпт сужается, чтобы поместиться рядом. Лента выше — это скроллбэк, поэтому панели закрепляются рядом с промптом, а не рядом с лентой. Ниже 140 колонок они встают стопкой над промптом.

Фокус ​

Клавиатура остаётся у промпта, пока панель её не заберёт, поэтому ввод никогда случайно не попадает в панель. Alt+↑ переводит фокус в новейшую открытую панель (её рамка подсвечивается, а промпт отходит в сторону, сохраняя набранное), а cmd.ui.openPane(id, {focus: true}) открывает панель сразу с фокусом, как диалог. Пока панель в фокусе:

КлавишаДействие
Tab / ↓следующая кнопка, поле ввода или select (по кругу)
Shift+Tab / ↑предыдущая
Enterнажать кнопку, отправить поле ввода или выбрать подсвеченный вариант select
←/→двигать подсветку select
ввод, Backspaceредактировать поле в фокусе (оно начинается со своего value)
Escотдать клавиатуру промпту. Панель остаётся открытой, и набранное в ней сохраняется, когда она снова получит фокус
Alt+0закрыть новейшую панель (фокус возвращается в промпт)

Ctrl+C по-прежнему доходит до прерывания и выхода промпта, пока панель в фокусе. Закрытие панели в фокусе или диалог, забирающий экран, тоже возвращают клавиатуру.

ts
cmd.ui.pane({
  id: 'standup',
  title: 'Standup',
  render: () => [
    { type: 'markdown', text: '**Today**: one line, then pick how it is going.' },
    {
      type: 'input',
      key: 'note',
      placeholder: 'what are you working on?',
      value: cmd.state.get<string>('note') ?? '',
      onSubmit: (text) => cmd.state.set('note', text)
    },
    {
      type: 'select',
      key: 'mood',
      options: [
        { label: 'Great', value: 'great' },
        { label: 'Blocked', value: 'blocked' }
      ],
      value: cmd.state.get<string>('mood'),
      onSelect: (value) => cmd.state.set('mood', value)
    }
  ]
})

cmd.addCommand({
  name: 'standup',
  handler: () => cmd.ui.openPane('standup', { focus: true })
})
ts
cmd.ui.pane({
  id: 'calls',
  title: 'Tool calls',
  render: () => [
    { type: 'progress', label: 'budget', value: calls() / 50 },
    {
      type: 'button',
      label: 'Reset',
      hotkey: 'r',
      onPress: () => cmd.state.set('calls', 0)
    }
  ]
})

cmd.addCommand({ name: 'calls', handler: () => cmd.ui.openPane('calls') })

onPress кнопки выполняется в моде как хук: исключение становится mod_error {hook: 'ui.press'}, а поверхность перерисовывается после каждого нажатия.

Отрисовка записей ленты ​

  • cmd.addRenderer(customType, data => lines) — рендерер для пользовательского типа записи; возвращает строки для печати (оформляйте их ANSI-последовательностями — picocolors, хелперы @commandcode/tui или сырые коды). При совпадении типов побеждает первая регистрация среди модов.
  • cmd.showEntry(customType, data) — отрисовать пользовательскую запись в живую ленту через рендерер, зарегистрированный для этого типа (типы без рендерера печатаются как JSON). TUI подключает приёмник; headless-запуски отбрасывают записи. В паре с cmd.session.appendCustomEntry, когда данные должны ещё и сохраняться.

Переоформление встроенных строк ​

cmd.ui.override(component, render) — перерисовать строку, которую Command Code рисует сам, теми же элементами, что и виджеты. render(props, next) получает данные строки и возвращает узлы либо undefined, чтобы оставить встроенную строку. Возвращённый Disposable снимает переопределение, а горячая перезагрузка сбрасывает его вместе со всеми прочими регистрациями.

КомпонентpropsЧто делают узлы
spinner{status, elapsedMs, tokens}добавляются после строки рабочего статуса, в ту же строку
tool_row{toolName, displayName, input, status, result?}рисуются вместо строки инструмента; status — pending, success или error, result задан, когда вызов завершился
user_message{text}рисуется вместо сообщения человека
command_output{message, details?, isError}рисуется вместо строки результата слэш-команды
ts
// "tool calls: 3" beside the spinner.
cmd.ui.override('spinner', (_props, next) => [
  { type: 'text', text: `tool calls: ${calls()}`, color: 'cyan' },
  ...(next() ?? [])
])

// Shell rows as one line; every other tool keeps its built-in row.
cmd.ui.override('tool_row', ({ toolName, input }) =>
  toolName === 'shell_command' ? [`$ ${input}`] : undefined
)

Как переопределения складываются и падают:

  • Порядок загрузки, побеждает первый ответ. Несколько модов могут переопределять один компонент. Побеждает первый мод, вернувший узлы; undefined передаёт дальше по цепочке, а после последнего — встроенной строке.
  • next() — остаток цепочки. Он возвращает то, что нарисовали моды, загруженные после этого, или undefined, когда рисовалась бы встроенная строка. Оберните его, чтобы дополнить чужую отрисовку, а не заменить её ([...mine, ...(next() ?? [])]). Поздние моды выполняются один раз, сколько бы раз вы его ни вызвали.
  • Сломанное переопределение откатывается. Переопределение, которое бросает исключение, возвращает не массив и не undefined (строку или Promise от async-render) либо рисует некорректное дерево, пропускается так, как будто вернуло undefined. Оно сообщает один mod_error {hook: 'ui.override:<component>'} до тех пор, пока мод не зарегистрирует его заново, поэтому сломанное переопределение спиннера не рапортует на каждом кадре.
  • Строки перерисовываются вместе со строкой. Спиннер перерисовывается примерно 12 раз в секунду; строка инструмента перерисовывается, пока она живая, и становится окончательной, когда уезжает в транскрипт. Переопределение применяется к строкам, нарисованным после его регистрации.
  • Строки инструментов и команд — поверхность доверия. Переопределение tool_row или command_output меняет то, что транскрипт показывает для вызова инструмента или команды. Устанавливайте только те моды, которым доверяете показывать эти строки честно.
  • Никогда — запрос разрешения. Переопределять можно только четыре компонента выше; любое другое имя бросает исключение при регистрации.

В десктопном приложении ​

Десктопное приложение рисует ту же поверхность вокруг поля ввода чата. Код мода по-прежнему выполняется только в главном процессе приложения: он рисует каждый виджет и панель там при фиксированных 80 колонках и отправляет в окно простые строки и идентификаторы кнопок, никогда не код.

  • Виджеты: above-editor стоит прямо над полем ввода, below-editor — прямо под ним, а строка статуса ниже.
  • Панели: открытая панель — карточка над полем ввода, с заголовком и кнопкой закрытия. Кнопки кликабельны там, где мод их нарисовал ([ r: Reset ]); сочетания Alt+hotkey — это особенность TUI.
  • Тосты: собственные тосты приложения с именем мода в заголовке.
  • Оформление: ANSI-жирный, тусклый и восемь цветов элементов отображаются на тему приложения; прочие escape-коды (перемещения курсора, ссылки, 256 цветов) отбрасываются.

В расширении VS Code ​

Расширение для IDE загружает моды из тех же мест, что и CLI, с одним хостом модов на сессию чата.

  • Команды модов появляются в меню слэш-команд. Результаты message и prompt, а также брошенные ошибки показываются в чате.
  • notify, toast, showEntry и mod_error становятся строками чата. confirm, select и input используют карточку вопроса.
  • Текст из setStatus появляется в строке состояния VS Code.
  • cmd.store использует общий с CLI каталог ~/.commandcode/mods/.store.
  • /mods перечисляет загруженные и упавшие моды. /mods reload [name] перезагружает их.

Моды проекта загружаются только для доверенной папки рабочего пространства.

Полная поддержка прочих возможностей модов появится позже.

Headless-поведение кратко ​

ПоверхностьИнтерактивный TUIДесктопное приложениеHeadless (-p)
notifyстрока notice в лентестрока notice в лентенапечатанное уведомление
toastстрока над промптом, исчезает саматост приложения, исчезает самстрока notice
confirmмодальное окновопрос в поле вводарезолвится false
select / inputмодальное окновопрос в поле вводарезолвится undefined
setStatusсегмент футера под вводомстрока статуса под полем вводасохраняется, нигде не рисуется
widgetполоса над или под промптомполоса над или под полем вводасохраняется, нигде не рисуется
paneпанель с рамкой над промптом (справа начиная с 140 колонок), кнопки Alt+клавиша, фокус Alt+↑карточка над полем ввода, кликабельные кнопкисохраняется, нигде не рисуется
overrideперерисовывает строки спиннера, инструментов, сообщений и командне рисуетсясохраняется, нигде не рисуется
setTitleзаголовок вкладки терминалаno-opno-op
showEntryотрисованная строка лентыотбрасываетсяотбрасывается
addShortcutдиспетчеризуется после биндингов хостаникогда не срабатываетникогда не срабатывает
sendUserMessageнастоящий ход пользователя (в очередь, пока занято)откат к queueMessageоткат к queueMessage
prompt.submitначинает ход, когда свободно, иначе в очередьочередь follow-upочередь follow-up
shutdownвыход после завершения ходаno-opno-op
ui.copyсистемный буфер обмена{isCopied: false, reason: 'headless'}{isCopied: false, reason: 'headless'}
audio.play / audio.bellплатформенный плеер / терминальный звонокбез звука, reason: 'headless'без звука, reason: 'headless'

Пока только TUI. cmd.ui.override, cmd.ui.setTitle, cmd.ui.copy, cmd.audio, cmd.addShortcut, cmd.shutdown, фокус панели, а также ввод в элементы input и выбор в select поддерживает только интерактивный TUI. Десктопное приложение рисует элемент input или select как обычный текст, которому не может дать фокус, а элемент markdown — как стилизованные строки.

Установка и обмен модами ​

Мод начинается как один файл в ~/.commandcode/mods/. Чтобы поделиться им с командой или кем угодно ещё, превратите его в пакет: npm-пакет, git-репозиторий или локальную папку. cmd mods add устанавливает пакет, и после этого Command Code загружает его в каждой сессии.

Управление модами из CLI ​

bash
cmd mods add cmd-mod-hi # npm package (a bare name means npm)
cmd mods add @team/review-mod@1.2.0 # scoped npm package, pinned to a version
cmd mods add owner/repo@v1 # GitHub repo (other hosts: git:<host>/…)
cmd mods add ./tools/local-mod # local folder, used where it is
cmd mods add -g owner/repo # install for your user instead of this project
cmd mods list # list installed mods (alias: ls)
cmd mods update # install anything missing, refresh pinned refs (alias: up)
cmd mods remove owner/repo # remove by source (alias: rm)
cmd mods remove fun-stuff # or by the name `mods list` prints
cmd mods enable <name> # turn a mod on; -g for your user
cmd mods disable <name> # turn a mod off; -g for your user
cmd mods open # open this project's mods folder; --user for yours
cmd mods inspect ./some-mod # show what a mod hooks and reaches, without running it

cmd mods list печатает по одной строке на мод: имя, область и источник. Сначала идут моды проекта, затем моды пользователя, затем моды из пакетов.

text
Mods (2)
guard · project · .commandcode/mods/guard.ts
cmd-mod-hi · user · from npm:cmd-mod-hi

Мод, который вы положили вручную, показывает свой файл. Мод из пакета показывает from <source>; его файлы лежат в .registry/, который вы никогда не правите руками. Встроенные моды в списке не появляются. Источник пакета появляется, только когда требует внимания: он ещё не установлен или установился, но не добавил ни одного мода.

Удаление. cmd mods remove принимает либо источник, либо имя мода из cmd mods list и удаляет то, что его предоставляет:

  • положенный вручную файл или папка удаляются из папки модов;
  • мод из пакета удаляет этот пакет и сообщает, какие ещё моды ушли вместе с ним;
  • у мода из mods.paths удаляется запись в настройках.

Команда сама находит нужную область, поэтому моду пользователя -g не нужен. Если источник не установлен ни в одной области, она так и говорит.

Открытие папки. cmd mods open открывает <project>/.commandcode/mods, а с --user (-g) — ~/.commandcode/mods, создавая папку при необходимости. --path печатает папку вместо открытия.

Как читаются и сохраняются источники.

  • Источник без слэша или @scope/name — это npm-пакет, поэтому префикс npm: необязателен. Префикс git: всегда означает git.
  • Источники сохраняются в ключе настроек mods.sources: .commandcode/settings.json для проекта, ~/.commandcode/settings.json с -g.
  • Версии и ссылки не меняют того, что это за источник: owner/repo@v1 и https://github.com/owner/repo — один и тот же пакет. Запись проекта имеет приоритет над тем же пакетом на уровне пользователя.
  • Пакеты устанавливаются в <scope>/.commandcode/mods/.registry/{npm,git}/…. Старт никогда сам не запускает npm или git. Пакет, который настроен, но не установлен, показывает предупреждение, указывающее на cmd mods update.

Превращение мода в пакет ​

Перечислите, что содержит пакет, в package.json. Записи могут быть точными путями, папками или глобами относительно корня пакета. Записи, указывающие за пределы корня, игнорируются.

json
{
  "name": "@team/review-mod",
  "description": "Blocks risky diffs and adds a /review command.",
  "keywords": ["cmd-mod", "cmd-mod-tool", "cmd-mod-command"],
  "repository": "https://github.com/team/review-mod",
  "commandcode": {
    "mods": ["./src/review.ts", "src/checks/*.ts"],
    "skills": ["./skills"]
  }
}

Без commandcode.mods Command Code ищет папку mods/, а затем корневой index.ts.

commandcode.skills перечисляет папки навыков, которые пакет поставляет рядом со своими модами. Каждая запись — одна папка навыка или папка навыков, относительно корня пакета. Они загружаются в каждой сессии, в которой загружается пакет, поэтому один cmd mods add приносит и моды, и их навыки. Мод может добавлять навыки и из кода, через cmd.addSkillsPath.

Добавьте ключевое слово cmd-mod, чтобы сопровождающие могли найти ваш пакет. Ключевые слова вида cmd-mod-tool и cmd-mod-hook описывают его подробнее. Публикация в npm сама по себе не добавляет ваш мод в хаб модов Command Code. Чтобы попасть в листинг, см. Листинг мода в хабе модов.

Загрузка только части модов пакета ​

Чтобы загрузить часть пакета, используйте объектную форму записи mods.sources:

json
{
  "mods": {
    "sources": [{ "source": "owner/review-pack", "mods": ["review/*.ts", "!review/slow.ts"] }]
  }
}

Шаблоны сопоставляются с путём файла внутри пакета или с именем мода. Есть четыре вида, применяемые в таком порядке:

  1. -path всегда исключает именно этот путь и перебивает всё остальное.
  2. +path всегда включает именно этот путь, даже если глоб его отбросил.
  3. !glob исключает совпадения.
  4. Обычный глоб включает совпадения. Если есть хотя бы одни включения, мод должен совпасть с одним из них.

cmd mods add и cmd mods remove сохраняют записи, написанные вами вручную, вместе с фильтрами.

Включение и выключение мода ​

cmd mods disable <name> выключает мод, не удаляя его, а cmd mods enable <name> включает обратно. Обе команды по умолчанию пишут в настройки проекта (.commandcode/settings.json), а с -g — в ваши пользовательские (~/.commandcode/settings.json). Изменение применяется в следующей сессии или после /reload.

bash
cmd mods disable review-guard          # off in this project
cmd mods enable review-guard -g        # on for your user

Они работают для положенных вручную файлов, установленных пакетов и тех встроенных модов, которые можно выключить (provider-copilot, update-notice, titling, learning и другие). disable добавляет имя в mods.disabled. enable убирает его оттуда, а для опционального встроенного мода вроде todos ещё и добавляет имя в mods.enabled:

json
{ "mods": { "disabled": ["review-guard"], "enabled": ["todos"] } }

Отключение в любом файле настроек побеждает. Command Code читает пользовательские, проектные и локальные проектные настройки (.commandcode/settings.local.json), и запись в mods.disabled в любом из них держит мод выключенным, даже если другой файл его включает. Поэтому если другой файл всё ещё отключает мод, который вы включаете, команда не сообщает об успехе. Она называет этот файл, даёт решение и выходит с кодом 1, чтобы цепочка && в скрипте остановилась:

text
$ cmd mods enable review-guard
⚠ review-guard is still disabled
  Your user settings (~/.commandcode/settings.json) disable it.
  To enable it at the user level, run: cmd mods enable review-guard -g

Ни одна команда не пишет в settings.local.json, поэтому отключение там нужно убирать руками.

В проекте, который вы ещё не открывали. Command Code читает настройки проекта только после того, как он в нём запускался, — то же правило, что и для модов проекта. Включение или выключение там всё равно сохраняет настройку, и она применяется, когда вы запустите Command Code в этом проекте. Используйте -g, чтобы применить её на уровне пользователя сразу. cmd mods list --builtin показывает, когда встроенный мод изменит состояние, например enabled · disabled once you start Command Code here.

Доверие и безопасность ​

  • Песочницы нет. Мод — это код, который работает с вашими правами, поэтому устанавливайте только те пакеты, которым доверяете.
  • Проверьте, прежде чем доверять. cmd mods inspect <path> читает исходник мода, не импортируя его, и перечисляет хуки, которые он регистрирует, что он добавляет и до чего может дотянуться: процессы, сеть, файловая система, окружение, ответы на разрешения и перезаписи транскрипта. Он читает только текст, поэтому код, который мод импортирует или собирает во время работы, может дотянуться дальше, и отчёт об этом говорит.
text
$ cmd mods inspect ./permission-policy.ts
files: 1
hooks: onPermissionRequest
registers: none
reaches: answers permission prompts (onPermissionRequest)
imports: none
  • Аварийные выключатели. cmd --safe-mode загружает только встроенные моды на одну сессию (никаких установленных модов и никаких --mod). {"mods": {"disableAll": true}} в любом файле настроек делает то же для всех сессий, пока вы это не уберёте.
  • Моды проекта требуют доверия к рабочему пространству, как и навыки проекта. Они загружаются только после того, как вы примете запрос доверия к рабочему пространству. Моды пользователя и моды из --mod загружаются всегда.
  • Пакеты устанавливаются через npm --ignore-scripts. Моды — это TypeScript, загружаемый напрямую, поэтому шаг сборки им не нужен, а install-скрипты только добавляли бы риск.
  • Print-режим загружает только моды пользователя и моды из --mod. Print-режим никогда не показывает запрос доверия, поэтому моды проекта не попадают в headless-запуски. Передайте --dangerously-skip-permissions, чтобы включить моды самого репозитория в headless-запуск, например в CI.

Листинг мода в хабе модов ​

Хаб модов Command Code перечисляет проверенные записи из mods/registry.json в публичном репозитории CommandCodeAI/command-code. Каждому моду в хабе нужна там запись — неважно, опубликован он в npm или живёт на GitHub. Публикация пакета или добавление ключевого слова его в листинг не добавляет.

Чтобы добавить свой мод в листинг:

  1. Опубликуйте его в npm или запушьте в публичный репозиторий GitHub. Проверьте, что в package.json правильные точки входа мода, описание и репозиторий.
  2. Откройте PR, который добавляет одну запись в mods/registry.json. Для npm-мода укажите и source, и npm полным именем пакета, включая его scope.
  3. Сопровождающие проверяют опубликованный код. После мерджа PR хаб модов подхватывает изменение примерно за 15 минут, без деплоя сайта.
json
{
  "version": 1,
  "hidden": [],
  "mods": [
    {
      "name": "review-guard",
      "source": "owner/review-guard",
      "description": "Blocks risky diffs and adds a /review command.",
      "author": { "name": "Ada", "github": "ada" },
      "repo": "https://github.com/owner/review-guard",
      "kinds": ["tool", "command"],
      "tags": ["safety", "review"]
    }
  ]
}
ПолеОбязательноЧто делает
nameдауникальное имя в нижнем регистре, которое показывает хаб модов
sourceдачто установит cmd mods add <source>: имя в npm или owner/repo
descriptionдакраткое описание на карточке хаба, до 240 символов
author.nameдакто автор. Добавьте author.github, чтобы сослаться на ваш профиль
kindsдахотя бы одно из tool, command, hook, provider, renderer, flag
repoнетURL на GitHub. Добавляет живой счётчик звёзд
npmнетопубликованный npm-пакет. Добавляет последнюю версию, дату публикации и месячные загрузки
tagsнеттеги поиска в нижнем регистре
addedAtнетYYYY-MM-DD, используется для сортировки по новизне

official помечает мод, который поддерживает команда Command Code, а список hidden верхнего уровня не пускает в хаб спам и самозванцев. И то и другое задают только сопровождающие. Полная схема — mods/registry.schema.json.

Список хаба модов, с живыми данными npm и счётчиками звёзд GitHub, также отдаётся по адресу commandcode.ai/mods.json. Он разрешает запросы с любого origin, поэтому CLI или любой скрипт может читать тот же список, что показывает хаб.

Проверка мода ​

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

1. Загрузите без установки ​

bash
cmd --mod ./your-mod.ts

--mod можно повторять, он загружается раньше установленных модов и выигрывает коллизии имён — самый быстрый цикл «попробовать». Шага сборки нет: jiti компилирует TypeScript при загрузке.

2. Убедитесь, что он зарегистрировался ​

bash
cmd mods list

Ваш мод должен появиться, без предупреждений при загрузке. Если его нет, предупреждение, напечатанное cmd mods list, объясняет причину (ошибка импорта, нет фабрики в default-экспорте, фабрика бросила исключение, дубликат имени).

3. Проверьте каждую поверхность, которую он регистрирует ​

  • Слэш-команда — введите / в поле ввода чата: команда должна появиться в автодополнении со своим описанием. Запустите её; {message} рисует информационную строку, {prompt} начинает автоматизированный ход.
  • Инструмент — попросите модель вызвать его по имени («вызови count_todos»). Вызов инструмента рисуется в ленте как любой встроенный.
  • Хук — вызовите поведение, которое он охраняет (для блокировщика beforeToolCall попросите запрещённое действие и посмотрите, как причина блокировки приходит в результате инструмента).
  • Перехват ввода — наберите шаблон, который ловит transformInput, и убедитесь, что перезапись или поглощение сработали.
  • Статус — cmd.ui.setStatus('text') должен появиться сегментом в строке под вводом, замениться при следующем вызове и исчезнуть по setStatus(null).
  • Виджет — виджет above-editor рисуется прямо над промптом, below-editor — под ним; cmd.ui.refreshWidgets() (или cmd.state.set) перерисовывает его.
  • Панель — откройте её (/mods open <pane> или командой самого мода): она рисуется над промптом, а Alt-клавиши её кнопок перечислены в последней строке; Alt+<key> нажимает одну, Alt+0 закрывает. Для полей ввода и select Alt+↑ даёт ей фокус: пройдите Tab по полям, введите текст, нажмите Enter и проверьте, что панель перерисовалась с новым value.
  • Тост — cmd.ui.toast('text') показывается над промптом и исчезает сам.
  • Переопределение — отправьте промпт, который запускает инструмент: переопределение spinner показывается рядом со строкой работы, переопределение tool_row заменяет строку этого инструмента. Если строка всё ещё выглядит встроенной, переопределение вернуло undefined или бросило исключение (ищите mod_error с ui.override: в поле hook).
  • Ответы на разрешения — с хуком onPermissionRequest вызовите действие, которое потребовало бы запроса: получивший ответ запрос никогда не появляется. cmd mods inspect перечисляет этот хук, так что ревьюеры его тоже видят.
  • Рендерер — строки из cmd.showEntry рисуются стилизованными; незарегистрированный тип печатается как JSON.

Тест: block-dangerous-commands сторожит rm -rf ​

text
Run rm -rf /tmp/scratch-dir

Ожидаемый результат:

  • разрешено: диалог подтверждения мода появляется до запуска shell-команды
  • разрешено: отказ блокирует инструмент — модель видит причину блокировки и адаптируется
  • заблокировано: для заблокированного вызова не срабатывает tool_running

4. Итерации: сохраните — и он перезагрузится ​

В интерактивном TUI сохранение файла мода перезагружает его на месте, без перезапуска: лента говорит [mods] ✓ reloaded <name> (или почему нет). Старый код получает onSessionEnd с reason: 'reload', затем освобождаются все его регистрации, кроме инструментов, добавленных через addTool. Они держат старый код до следующей сессии. cmd.state сохраняется, а onSessionStart срабатывает снова с source: 'reload'. Сохранение, которое падает при загрузке, оставляет работать старый код. /mods reload [name] перезагружает по требованию. Двум вещам нужно больше, чем перезагрузка: инструменты (набор инструментов фиксирован на сессию, поэтому начните новую сессию или /reload) и новые файлы модов (/reload находит их заново). COMMANDCODE_MODS_HOT_RELOAD=0 отключает наблюдатель.

5. Проверка в headless-режиме (CI) ​

bash
cmd -p "exercise the mod" --mod ./your-mod.ts

Print-режим загружает моды уровня пользователя и моды из --mod, а UI-мост деградирует до детерминированных значений по умолчанию (confirm → false, select/input → undefined). Мод, который должен работать в CI, должен разумно вести себя при этих значениях.

6. Добавьте мод в хаб модов ​

Опубликуйте мод, затем добавьте его в хаб модов Command Code через PR в публичный реестр. Шаги и поля — в разделе Листинг мода в хабе модов.

Работа со встроенными примерами ​

Попросите Command Code собрать нужный мод — и он начнёт со встроенного примера, а не с пустого файла; список примеров — в разделе Запускаемые примеры. Каждый из них в CI проходит через настоящий загрузчик модов на каждом тестовом прогоне, так что то, с чего вы начинаете, гарантированно загружается.

Источник

Сверено с оригиналом 13 ноября 2026.