Моды
Экспериментально
ModApi новый и ещё только формируется. Поверхности и сигнатуры могут меняться, пока мы учимся на том, как моды пишут, — поэтому фиксируйте то, что выпускаете, и рассказывайте, чего не хватает. Именно ваша обратная связь делает его стабильным.
Моды — это TypeScript-пакеты, которые позволяют менять почти любое поведение Command Code. Они куда мощнее API плагинов и расширений, которые вы найдёте в других агентах, — хотя с их помощью вполне можно собрать плагин или расширение. Каждый мод пишется против ModApi: это обычный TypeScript-файл, который Command Code находит на диске, загружает при старте и компилирует в свой цикл агента. Один файл может добавить инструменты, которые вызывает модель, слэш-команды, мутирующие хуки жизненного цикла, наблюдателей событий, перехват типизированного ввода, ответы на запросы разрешений, редактирование транскрипта, маршрутизацию модели по запросам, панели и виджеты с кнопками, тосты, типы субагентов, настраиваемые флаги и провайдеров моделей. Моды перезагружаются на лету, когда вы их сохраняете. Собственные встроенные возможности Command Code — провайдеры, автоименование сессий, уведомление об обновлении — сделаны как моды против этого же API. Если Command Code умеет что-то, мод может это изменить.
Загружаемый мод после загрузки и есть AgentMod — хост модов просто собирает его из файла-фабрики, а не из импорта кода:
~/.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 и будет дорабатывать, пока не заработает. Самый быстрый вход — спросить, что вообще возможно, а затем попросить конкретный мод:
What kinds of mods can I build for Command Code? Show me a few examples.Build me a mod that roasts me every time I greet you with "hi".Add a /standup slash command that summarizes what changed in git today.Block any shell command containing "rm -rf" unless I confirm it first.Под капотом он читает встроенные примеры и пишет код против того же ModApi, который описан на этой странице. Когда всё готово, cmd --mod ./your-mod.ts сразу его пробует — без шага сборки.
Настоящий мод, собранный по запросу
cmd-mod-hi начался ровно с такого промпта — «подкалывай меня, когда я говорю hi» — и вышел в npm. Установите его и поздоровайтесь с агентом:
cmd mods add npm:cmd-mod-hi -gЗатем введите hi, yo, howdy или hello и посмотрите, как он вас оценивает; /hi-stats подсчитывает урон. Весь мод — это один хук transformInput плюс рендерер:
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:
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/ именно затем, чтобы обнаружение его пропускало). При совпадении имён остаётся первый мод, а выводится предупреждение. Отключить мод, не удаляя его, можно настройками:
{ "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 и подчиняются тому же ключу отключения:
{ "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 файла настроек:
{ "mods": { "enabled": ["todos"] } }cmd mods disable <name> отключает мод. Отключение в любом файле настроек побеждает и оставляет мод выключенным. Изменение вступает в силу в следующей сессии или по /reload; см. Включение и выключение мода.
Встроенные моды всегда выигрывают коллизию имён у обнаруженного мода с тем же именем (загрузчик затеняет файл с предупреждением), и они скомпилированы внутрь — никогда не загружаются jiti из записываемого пути, поэтому не являются поверхностью атаки цепочки поставок. Структурные моды харнесса (рабочее пространство, компакция, чекпоинты) критичны для корректной работы и остаются безусловными; отключать можно только observer-встроенные моды, перечисленные выше.
Запускаемые примеры
Command Code поставляет запускаемые однофайловые моды-примеры и сам их читает. Искать их на диске не нужно — просто попросите:
build a mod that blocks dangerous shell commandsCommand Code откроет подходящий пример и будет работать от него. Каждый пример в CI проходит через настоящий загрузчик модов на каждом тестовом прогоне, так что скопированное гарантированно загружается.
Просите любой из них по имени или по тому, что вам нужно:
| Файл | Что показывает |
|---|---|
slash-command.ts | addCommand — команда /command, возвращающая {prompt} или {message} |
custom-tool.ts | addTool — инструмент, вызываемый моделью, с run + exec |
block-dangerous-commands.ts | hooks.beforeToolCall — блокировка и разрешение с подтверждением |
input-shortcuts.ts | hooks.transformInput — переписывание и обработка типизированного ввода |
observe-events.ts | on(event) + кросс-модовая шина events |
custom-entry-renderer.ts | addRenderer + showEntry — стилизованные строки ленты |
flags-and-options.ts | addFlag / getFlag + --mod-option |
lifecycle-hooks.ts | hooks.onStop (Stop) + onSessionStart/onSessionEnd + afterToolCall isError + on('subagent_start'/'subagent_stop') |
pane-and-buttons.ts | ui.pane + ui.widget + кнопки + cmd.state + ui.toast |
pane-form.ts | панель-форма в фокусе: элементы input + select + markdown, openPane(id, {focus: true}) |
spinner-count.ts | ui.override — счётчик вызовов инструментов рядом со спиннером, компактные строки shell |
redact-secrets.ts | hooks.transformMessage — маскирование ключей до того, как их сохранит транскрипт |
permission-policy.ts | hooks.onPermissionRequest — ответы на запросы по политике команды |
model-router.ts | hooks.beforeModelRequest + cmd.model.complete |
stream-filter.ts | hooks.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: каждое поле, метод регистрации и живой метод.
- Хуки и события: мутирующий жизненный цикл и каталог
AgentEvent. - UI-поверхность: диалоги, статус в футере, виджеты редактора, отрисовка записей ленты.
- Установка и обмен модами:
cmd mods add, пакеты, фильтрация, включение и выключение модов. - Листинг мода в хабе модов: как добавить свой мод в хаб модов Command Code.
- Проверка мода: загрузить, перечислить, протестировать, выпустить.
Справочник ModApi
export default function (cmd: ModApi) — фабрика. ModFactory = (cmd: ModApi) => void | Promise<void>. Каждый вызов add* / on / hooks возвращает Disposable { dispose(): void } — .dispose() отменяет ровно эту регистрацию (идемпотентно; второй вызов ничего не делает). Disposable делают регистрацию отзываемой и служат швом, который в будущем переиспользует горячая перезагрузка.
Поля
| Член | Тип | Примечания |
|---|---|---|
cmd.name | string | имя мода (из имени файла / манифеста) |
cmd.cwd | string | корень рабочего пространства |
cmd.session | ModSessionApi | undefined | шов персистентности; undefined, пока не привязан |
cmd.events | ModEventBus | emit(channel, data?) / on(channel, handler) — кросс-модовая шина |
cmd.ui | ModUi | notify / confirm / select / input / setStatus / widget / refreshWidgets / pane / openPane / closePane / toast / copy / override / setTitle / capabilities |
cmd.sessions | ModSessionControls | compact / tree / leafId / navigateTree / setLabel |
cmd.state | ModStateApi | get / set / subscribe — значения сессии, переживающие горячую перезагрузку |
cmd.store | ModStoreApi | get / set / delete — значения, живущие между сессиями (~/.commandcode/mods/.store/<mod>.json) |
cmd.clock | ModClockApi | every / after — таймеры, отменяемые вместе с модом |
cmd.model | ModModelApi | complete — один промпт на модельном клиенте сессии |
cmd.prompt | ModPromptApi | submit — начать собственный ход, когда сессия свободна |
cmd.process | ModProcessApi | spawn — запустить процесс и передавать его вывод по мере поступления |
cmd.audio | ModAudioApi | play / 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() = один ход пользователя → много ходов модели («раундов»). Цикл ниже — это цикл агента, дословно по порядку; хуки модов помечены ◆, события — →.
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}Порядок диспетчеризации инструментов
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может толкнуть завершённый запуск дальше.
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.
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 мода:
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_blocked | beforeToolCall мода заблокировал вызов | hookOutput — терминальное для этого вызова: за ним не следует tool_running/completed/errored |
tool_running | начинается исполнение (после beforeToolCall) | description из permissions.generateDescription |
tool_update | стриминг прогресса инструмента | содержимое partial |
tool_completed / tool_errored | после afterToolCall | result / текст 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 | навык активирован явным вызовом пользователя /name | name |
session_titled | автоматически сгенерированное имя сессии сохранено | title |
permission_mode_changed | действующий режим разрешений изменился (shift+tab, /plan, инструменты входа и выхода из режима планирования) | mode |
config_setting_changed | настройка изменена через инструмент config | setting, value, previousValue? |
continuation_recovery | ход был автоматически продолжен (pause/empty/length/intent) | kind, attempt, maxAttempts |
tool_input_coerced | ModelClient спас некорректный (массив/null) ввод инструмента | rawType, recovered |
tool_input_repaired | слой починки переписал ввод инструмента до исполнения (срабатывает независимо от того, запустился ли вызов) | rulesFired, hintCount, receivedKeys, repaired |
mod_error | хук мода бросил исключение (на любой фазе) или инструмент мода столкнулся с существующим инструментом | modId, hook, error |
interrupted | замечено прерывание | - |
run_error | неповторяемый сбой | сырой Error |
Пример: мод квоты записи
Блокирует запись вне белого списка, долговечно считает активность инструментов в modState и отчитывается в конце запуска. Задействует канал блокировки, разделение замыкания и modState, а также onRunEnd.
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, PowerShellMedia.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 в хуке, если ход не должен его дожидаться:
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, — в разделе Добавьте свои элементы с помощью модов.
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). Используйте их, чтобы выбрать поверхность, а не поставлять постоянный фолбэк:
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: сохраняется, но нигде не рисуется.
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 по-прежнему доходит до прерывания и выхода промпта, пока панель в фокусе. Закрытие панели в фокусе или диалог, забирающий экран, тоже возвращают клавиатуру.
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 })
})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} | рисуется вместо строки результата слэш-команды |
// "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-op | no-op |
showEntry | отрисованная строка ленты | отбрасывается | отбрасывается |
addShortcut | диспетчеризуется после биндингов хоста | никогда не срабатывает | никогда не срабатывает |
sendUserMessage | настоящий ход пользователя (в очередь, пока занято) | откат к queueMessage | откат к queueMessage |
prompt.submit | начинает ход, когда свободно, иначе в очередь | очередь follow-up | очередь follow-up |
shutdown | выход после завершения хода | no-op | no-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
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 itcmd mods list печатает по одной строке на мод: имя, область и источник. Сначала идут моды проекта, затем моды пользователя, затем моды из пакетов.
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. Записи могут быть точными путями, папками или глобами относительно корня пакета. Записи, указывающие за пределы корня, игнорируются.
{
"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:
{
"mods": {
"sources": [{ "source": "owner/review-pack", "mods": ["review/*.ts", "!review/slow.ts"] }]
}
}Шаблоны сопоставляются с путём файла внутри пакета или с именем мода. Есть четыре вида, применяемые в таком порядке:
-pathвсегда исключает именно этот путь и перебивает всё остальное.+pathвсегда включает именно этот путь, даже если глоб его отбросил.!globисключает совпадения.- Обычный глоб включает совпадения. Если есть хотя бы одни включения, мод должен совпасть с одним из них.
cmd mods add и cmd mods remove сохраняют записи, написанные вами вручную, вместе с фильтрами.
Включение и выключение мода
cmd mods disable <name> выключает мод, не удаляя его, а cmd mods enable <name> включает обратно. Обе команды по умолчанию пишут в настройки проекта (.commandcode/settings.json), а с -g — в ваши пользовательские (~/.commandcode/settings.json). Изменение применяется в следующей сессии или после /reload.
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:
{ "mods": { "disabled": ["review-guard"], "enabled": ["todos"] } }Отключение в любом файле настроек побеждает. Command Code читает пользовательские, проектные и локальные проектные настройки (.commandcode/settings.local.json), и запись в mods.disabled в любом из них держит мод выключенным, даже если другой файл его включает. Поэтому если другой файл всё ещё отключает мод, который вы включаете, команда не сообщает об успехе. Она называет этот файл, даёт решение и выходит с кодом 1, чтобы цепочка && в скрипте остановилась:
$ 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>читает исходник мода, не импортируя его, и перечисляет хуки, которые он регистрирует, что он добавляет и до чего может дотянуться: процессы, сеть, файловая система, окружение, ответы на разрешения и перезаписи транскрипта. Он читает только текст, поэтому код, который мод импортирует или собирает во время работы, может дотянуться дальше, и отчёт об этом говорит.
$ 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. Публикация пакета или добавление ключевого слова его в листинг не добавляет.
Чтобы добавить свой мод в листинг:
- Опубликуйте его в npm или запушьте в публичный репозиторий GitHub. Проверьте, что в
package.jsonправильные точки входа мода, описание и репозиторий. - Откройте PR, который добавляет одну запись в
mods/registry.json. Для npm-мода укажите иsource, иnpmполным именем пакета, включая его scope. - Сопровождающие проверяют опубликованный код. После мерджа PR хаб модов подхватывает изменение примерно за 15 минут, без деплоя сайта.
{
"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. Загрузите без установки
cmd --mod ./your-mod.ts--mod можно повторять, он загружается раньше установленных модов и выигрывает коллизии имён — самый быстрый цикл «попробовать». Шага сборки нет: jiti компилирует TypeScript при загрузке.
2. Убедитесь, что он зарегистрировался
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закрывает. Для полей ввода и selectAlt+↑даёт ей фокус: пройдите 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
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)
cmd -p "exercise the mod" --mod ./your-mod.tsPrint-режим загружает моды уровня пользователя и моды из --mod, а UI-мост деградирует до детерминированных значений по умолчанию (confirm → false, select/input → undefined). Мод, который должен работать в CI, должен разумно вести себя при этих значениях.
6. Добавьте мод в хаб модов
Опубликуйте мод, затем добавьте его в хаб модов Command Code через PR в публичный реестр. Шаги и поля — в разделе Листинг мода в хабе модов.
Работа со встроенными примерами
Попросите Command Code собрать нужный мод — и он начнёт со встроенного примера, а не с пустого файла; список примеров — в разделе Запускаемые примеры. Каждый из них в CI проходит через настоящий загрузчик модов на каждом тестовом прогоне, так что то, с чего вы начинаете, гарантированно загружается.
Источник
Сверено с оригиналом 13 ноября 2026.