English · Русский · 简体中文 · Документация
Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Он также может объявить долгоживущие сервисы. Web-contributions работают без Node.js. Хуки и сервисы — нативный код: каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в Настройки → Агенты → Хуки, а сервисы плагина — пока он не подтвердит их в Настройки → Агенты → Нативный код расширений.
Установка плагина разрешает стороннему browser-коду выполняться локально. CanvasTTY уменьшает поверхность риска, но не может сделать неизвестный код доверенным:
- CanvasTTY скачивает только tar-архив default branch по корневой ссылке GitHub-репозитория и не запускает
npm install, build hooks, нативные модули или scripts репозитория во время установки/обновления. - В пакете запрещены symlink; лимит — 500 файлов или каталогов / 25 МБ, один отдаваемый ресурс — не больше 8 МБ.
- Iframe получает opaque sandbox origin, не видит parent DOM,
window.canvasTTYи Node.js API. - Узкий preload отдельного окна не открывает Node primitives и передаёт те же SDK-запросы через IPC с проверкой plugin/contribution по фактическому URL.
- Каждый привилегированный SDK-метод требует permission из manifest. Полный список разрешений показывается до подтверждения установки.
- Sandboxed web-contributions не получают учётные данные провайдеров, PTY buffer, рабочие каталоги, сырые ответы API или доступ к файловой системе.
- Выключение или удаление плагина сразу прекращает отдачу его ресурсов и закрывает отдельные окна.
- Сервисы подчиняются тому же правилу, что и хуки, для плагина целиком: установка их не запускает, а обновление, смена modules, выключение или изменённый файл entry снимают подтверждение. Сервисы работают вне процесса; код плагинов не выполняется в main-процессе CanvasTTY.
- Agent hooks никогда не включаются автоматически. Включённый hook script эквивалентен нативному приложению: он получает payload события агента, выполняется с правами учётной записи пользователя и потенциально видит доступные ей конфиги или credentials. Обновление, смена modules или выключение плагина отзывает все такие разрешения.
CanvasTTY не встраивает произвольные нативные окна ОС. Contribution window — это sandboxed BrowserWindow, которым владеет CanvasTTY. Native reparenting ненадёжен и непереносим между Wayland, macOS, Windows, разными DPI, popup и GPU surfaces.
В корне репозитория обязателен canvastty.plugin.json. Entry — относительный путь к готовому статическому HTML; inline scripts блокируются plugin CSP.
canvastty.plugin.json
shared/plugin.css
widgets/status.html
widgets/status.js
apps/notes.html
apps/notes.js
windows/focus.html
windows/focus.js
hooks/audit.mjs
Рабочий пример sandboxed web-поверхностей без привилегированного хука: examples/plugins/studio-kit. Минимальный сервис с canvas-приложением, которое его вызывает: examples/plugins/service-echo. Launch contributor: examples/plugins/launch-env, политика запуска: examples/plugins/yolo-guard. Среда сессии (git worktree): examples/plugins/env-worktree. Сервис решений: examples/plugins/deny-rm. Инструмент для агентов, действие на окне и события сессий: examples/plugins/collect-demo.
Для IDE доступны JSON Schema manifest и TypeScript declarations SDK.
{
"apiVersion": 1,
"id": "com.example.studio-kit",
"name": "Studio Kit",
"version": "1.0.0",
"description": "Небольшие поверхности CanvasTTY на реальных данных host.",
"permissions": ["storage", "secrets", "sessions:read", "launcher:open"],
"hooks": [
{
"id": "audit",
"title": "Локальный журнал аудита",
"description": "Записывает выбранные события жизненного цикла агента в журнал под управлением пользователя.",
"entry": "hooks/audit.mjs",
"providers": ["codex", "claude", "kimi"],
"events": ["session-start", "permission-request", "session-end"]
}
],
"settingsContribution": "notes",
"contributions": [
{
"id": "session-status",
"kind": "home-widget",
"title": "Session status",
"entry": "widgets/status.html",
"defaultSize": { "columns": 4, "rows": 2 }
},
{
"id": "notes",
"kind": "canvas-app",
"title": "Notes",
"entry": "apps/notes.html",
"defaultSize": { "width": 680, "height": 440 },
"minSize": { "width": 320, "height": 180 }
},
{
"id": "focus",
"kind": "window",
"title": "Focus",
"entry": "windows/focus.html",
"defaultSize": { "width": 900, "height": 620 }
}
]
}ID плагина и contribution — стабильные ключи persistence: после публикации их нельзя переименовывать. Версия использует semantic version. Опциональный settingsContribution ссылается на один canvas-app: CanvasTTY показывает для него отдельное действие Настройки в меню расширений. Каждый установленный home-widget также появляется рядом со встроенными виджетами в разделе Настройки → Оформление → Состав HOME, где он добавляется или удаляется. Опциональный minSize поддерживается для canvas-app и window, не может превышать defaultSize и ограничен снизу размером 240 × 140 px. Для старых manifest сохраняется минимум хоста 320 × 220 px. HOME начинает с просторной логической сетки 16 × 12, сохраняя исходную композицию 12 × 8. В редакторе видимая граница растягивается до 48 × 36 без уменьшения ячеек, а при нехватке места новый виджет расширяет её автоматически. Canvas app использует world-space pixels и участвует в том же snapping, что терминальные карточки.
Поле platforms необязательно; если оно задано, список должен содержать "canvastty", иначе прямая установка или обновление отклоняются. minHostVersion носит информационный характер: витрина помечает плагины для более новой версии host, но не блокирует установку. Старые минимальные версии не считаются несовместимостью.
Модульный manifest объявляет проверяемые по целостности coreFiles и до 16 необязательных modules. Для каждого файла задаются path, точный размер bytes и SHA-256. CanvasTTY загружает для предпросмотра только manifest, показывает галочки, размер и разрешения каждого модуля, а затем скачивает только ядро и выбранные модули. Последующее изменение выбора атомарно заменяет установленный пакет и удаляет файлы отключённых модулей. Поле module у contribution скрывает его, если соответствующий модуль не установлен.
Целостность файлов модулей (точный размер в байтах и SHA-256) проверяется по хэшам, объявленным в manifest плагина, а сам manifest загружается с GitHub по TLS без отдельной подписи. Поэтому якорем доверия является GitHub-репозиторий плагина: скомпрометированный репозиторий может опубликовать новый manifest с совпадающими хэшами.
Поле hooks объявляет до 16 JavaScript entries (.js, .mjs, .cjs). Для каждого задаются стабильные id, title, entry, список providers и семантические events: session-start, prompt-submit, permission-request, permission-result, after-tool, stop, session-end. Не поддерживаемые конкретным provider события пропускаются. В modular plugin entry хука должен быть integrity-объявлен в его опциональном module, а без module — в coreFiles. Non-modular пакет обязан содержать файл по проверенному entry path.
Hook-only plugin использует пустой массив contributions и непустой hooks. Установка только копирует и проверяет файлы. Перед включением пользователь должен проверить исходник и репозиторий, затем отдельно подтвердить доверие в Настройки → Агенты → Хуки. Host-owned registry проверяется при каждом вызове, поэтому выключение блокирует последующие запуски даже в уже работающей сессии. Если launch-time bridge provider ещё не установлен, для включения понадобится новая или перезапущенная сессия агента.
Собственная проверка хуков provider остаётся обязательной. Например, Codex может дополнительно попросить проверить launch-time bridge CanvasTTY через свой /hooks. CanvasTTY не передаёт глобальный флаг Codex --dangerously-bypass-hook-trust: включение plugin hook не ослабляет проверку других хуков provider.
Script запускается отдельным процессом из каталога плагина и получает JSON через stdin с полями apiVersion, pluginId, hookId, terminalSessionId, provider, event, providerEvent, payload. Stdout/stderr отбрасываются, время выполнения ограничено, внутренние capability-токены CanvasTTY удаляются из environment. Это защита host internals, а не sandbox: script всё ещё может читать/менять файлы и запускать процессы с обычными правами пользователя.
Манифест с "apiVersion": 2 может объявить до 8 services. Манифесты версии 1 остаются валидными; версия 2 нужна только для services.
"services": [
{ "id": "echo", "title": "Echo", "description": "Отвечает эхом.", "entry": "services/echo.mjs" }
]У сервиса стабильный id, title, необязательные description и module, и entry с расширением .js, .mjs или .cjs. Entry должен быть собранным одним файлом (например, esbuild): установщик ничего не собирает и не запускает npm install, Electron и node-pty сервису недоступны. В модульном плагине entry должен быть объявлен с хешем в своём module (или в coreFiles), как entry хуков. Когда пользователь доверяет нативному коду плагина, CanvasTTY запоминает SHA-256 entry и проверяет его перед каждым запуском; изменённый файл не запускается, а доверие снимается при следующем старте.
Жизненный цикл: каждый сервис включённого и доверенного плагина работает отдельным процессом (process.execPath с ELECTRON_RUN_AS_NODE=1), рабочая папка — папка плагина. Окружение минимальное: PATH, HOME, пользователь, shell, локаль, временные и XDG-папки, SSH_AUTH_SOCK и системные папки Windows. Ключи провайдеров, токены, NODE_OPTIONS и все CANVASTTY_* удаляются. Неожиданно завершившийся сервис перезапускается через 1, 2, 4, 8, затем 16 с; после более чем 5 неожиданных завершений за 10 минут он остаётся в ошибке, пока доверие не подтвердят заново. Выключение, удаление, обновление, смена модулей, снятие доверия или выход из CanvasTTY останавливают его: сначала уведомление canvastty.shutdown и закрытый stdin, затем SIGTERM, затем SIGKILL. Для сервиса создаётся <userData>/plugin-data/<pluginId>, при удалении плагина папка удаляется. stderr, stdout вне протокола, вызовы log и события жизненного цикла пишутся в ограниченный журнал плагина (последние 300 записей) в Настройки → Агенты → Нативный код расширений.
Протокол: JSON-RPC 2.0 построчно через stdin/stdout, не больше 1 МБ на сообщение в каждую сторону. Более крупный запрос хоста отклоняется, более длинная строка сервиса отбрасывается и попадает в журнал. Первым хост отправляет уведомление canvastty.initialize с { apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }.
При запуске приложения сервисы стартуют только после того, как готовы все методы хоста, которые они могут вызвать (sessions.*, cards.setBadge, secrets.get, …), и до восстановления сохранённых окон: сервис может вызывать их сразу после canvastty.initialize, а подписавшийся на события сессий получает восстановленные окна событиями или в снимке sessions.subscribe.
Запросы от собственных поверхностей плагина приходят с методом и параметрами, которые выбрала поверхность; методы с префиксом canvastty. зарезервированы за хостом. Отвечайте {"jsonrpc":"2.0","id":…,"result":…} или {"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}. Запрос без ответа за 15 с завершается ошибкой таймаута, как и запрос к остановленному, перезапускающемуся или упавшему сервису; одновременно ждут не больше 64 запросов на сервис.
Сервис может вызывать API хоста (это основа, которую расширят следующие точки расширения; всё остальное получает ошибку -32601):
| Метод | Вид | Условие | Результат |
|---|---|---|---|
log { level?: "info" | "warn" | "error", message } |
запрос или уведомление | нет | Строка в журнале плагина |
storage.get { key } |
запрос | разрешение storage |
То же изолированное хранилище 64 КБ, что host.storage.get |
storage.set { key, value } |
запрос | разрешение storage |
Запись и уведомление поверхностей плагина |
event { event, data } |
уведомление | нет | Доставляется открытым поверхностям плагина через host.service.onEvent |
redaction.register { values } |
запрос | нет | До 32 строк (до 4096 символов, учитываются от 8), которые CanvasTTY маскирует во всём тексте, который один агент читает у другого; хранятся только в памяти |
secrets.get { key } |
запрос | разрешение secrets |
Собственный секрет плагина (то же хранилище, что host.secrets) или null. Значение затем маскируется, как значения redaction.register. Для ключей, которые нужны самому сервису (API-ключ модели, которую он вызывает); никогда не отправляйте его обратно на страницу |
sessions.subscribe / sessions.list / sessions.unsubscribe |
запрос | sessions:events |
События окон и список открытых окон (см. «События сессий») |
sessions.create |
запрос | sessions:launch |
Запускает окно, которым владеет плагин |
sessions.send / sessions.stop |
запрос | sessions:control |
Только для окон, запущенных этим плагином |
cards.setBadge { sessionId, badge } |
запрос | cards:decorate |
Короткая текстовая метка на любом окне (см. «Метки и действия на окнах») |
Хост привязывает каждый вызов к плагину самого сервиса: сервис не может назвать другой плагин или прочитать секреты другого плагина, а к сессиям обращается только через разрешения sessions:* ниже. Пример service-echo сохраняет токен со своей страницы через host.secrets.set, а его сервис читает его через secrets.get и отвечает только, задан ли он.
Канал UI: sandboxed-поверхности обращаются только к сервисам своего плагина:
const reply = await host.service.request("echo", "echo", { text: "hi" });
host.service.onEvent(({ serviceId, event, data }) => { /* … */ });Разрешение неявное, если плагин объявил сервис. Хост передаёт непрозрачный JSON и никогда не добавляет credentials. Запрос к неработающему сервису (ещё не доверен, выключен, перезапускается, упал) или по таймауту завершается ошибкой.
Один сервис плагина может объявить блок launch. Его поля появляются в окне запуска агента в разделе Дополнительно, когда нативному коду плагина доверяют; человек включает плагин для одного запуска флажком Использовать имя плагина и задаёт поля. Плагин спрашивают только о запусках, где его выбрали, и о перезапусках и восстановлении этих окон.
"permissions": ["launch:contribute"],
"services": [{
"id": "launcher", "title": "Launch env", "entry": "services/launcher.mjs",
"launch": {
"appliesTo": ["claude"],
"fields": [
{ "key": "enabled", "label": "Add the variable", "kind": "boolean", "default": true },
{ "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 },
{ "key": "mode", "label": "Mode", "kind": "select", "default": "plain",
"options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] }
]
}
}]До 8 полей; kind: boolean, select (1–16 вариантов) или text (до 200 символов или maxLength). appliesTo перечисляет провайдеров-агентов; без него плагин доступен всем агентам. Выбранные значения проверяются по полям, сохраняются в записи сессии окна (до 4 КБ на плагин) и используются при перезапуске и восстановлении. Они не секретны: ключи хранят в secrets плагина, а не в поле.
select с "optionsFrom": "service" дополнительно показывает варианты, которые предлагает сервис, например его собственные учётные записи. При открытии окна запуска CanvasTTY спрашивает сервис canvastty.launch.options { provider, fields: [ключи] } и ждёт не более 3 с; ответ { "<ключ>": [{ value, label }] } добавляет до 64 вариантов на поле после объявленных (они по-прежнему обязательны, и только они видны, если сервис не ответил). Такой список может измениться после сохранения окна, поэтому значение принимается как любой текст до 200 символов без управляющих символов, а canvastty.launch.prepare обязан его проверить и отказать, если такого варианта больше нет.
Оркестраторы передают те же значения в spawn_agent как launchOptions ({ "<pluginId>": { "<ключ>": значение } }); они проверяются так же, как значения окна запуска. Их может выдать инструмент плагина (например, выбранную им учётную запись). Пока запуск дочернего агента ждёт плагинов (параметры запуска, политика запуска, среда), spawn_agent отвечает только после того, как его prompt дошёл до запущенного агента; send_to_agent ждёт так же. Отказ, сбой или отмена запуска завершают вызов ошибкой с причиной и id сессии (окно остаётся); текст отбрасывается и не сохраняется для следующего перезапуска. CLI управления для такого окна отвечает NOT_READY.
Перед запуском агента хост отправляет сервису запрос canvastty.launch.prepare (поверхности его отправить не могут):
{"sessionId":"…","provider":"claude","profile":"normal","role":"agent","cwd":"/project","restoring":false,"resume":false,"options":{"enabled":true,"greeting":"hello","mode":"plain"},"chosen":true,"environment":null}Ответ: null (ничего не добавлять) или объект с любыми из ключей:
| Ключ | Предел | Действие |
|---|---|---|
env { NAME: value } |
32 имени, 8 КБ на значение | Добавляется в окружение агента |
secretEnv { NAME: secretKey } |
16 имён; нужно secrets |
Хост читает собственный секрет плагина в main-процессе и подставляет его. Значение не попадает ни в сервис, ни в UI и маскируется как <redacted:secret> в тексте этого окна, который читают другие агенты и control CLI (observe, result, screen, причина ошибки) |
args [string] |
32, до 1024 символов, без управляющих символов | Добавляются после аргументов CanvasTTY, перед выбором разговора |
files [{ relPath, content }] |
16 файлов, 256 КБ, простые относительные пути | Пишутся в личную папку этого запуска и удаляются при выходе процесса; {launchFiles} в env и args заменяется на эту папку |
thirdPartyModel true |
— | Агент работает на модели не своего поставщика (аккаунт API или Ollama). Профиль auto тогда в этом запуске работает как «только правки», и окно это показывает. Может задавать и политика запуска: отметка только ужесточает запуск |
refuse { reason } |
240 символов | Окно не запускается и показывает причину |
Правила хоста, которые никогда не пропускаются:
- Выбранные плагины опрашиваются параллельно, результаты объединяются в порядке id плагинов. Если два плагина задают одно имя или плагин задаёт имя, которое CanvasTTY задаёт для этого запуска, запуск отклоняется с их названиями. Имена с префиксами
CANVASTTY_,ELECTRON_,DYLD_,LD_, а такжеNODE_OPTIONS,PATH,TERM,COLORTERMзарезервированы. - Аргументы, которые отключают подтверждения или выбирают разговор (YOLO-флаги всех провайдеров,
--permission-mode,--sandbox,--resume,--continue,--sessionи подобные), отклоняются: профиль остаётся за человеком, правила восстановления за ядром. Это не песочница: доверенный нативный код и так работает от вашего имени. - Claude Code применяет только последний
--settings, поэтому встроенный JSON--settingsплагина сливается с собственным JSON CanvasTTY (объекты вродеenvпо ключам, списки хуков дописываются); если он задаётpermissions,hooks,disableAllHooks,sandbox,defaultModeилиapiKeyHelper, запуск отклоняется. Проверяются обе формы:--settings <json>и--settings=<json>; файл настроек принимается только как один из собственных файлов запуска вклада ({launchFiles}/…) — CanvasTTY читает его, проверяет так же и передаёт встроенным JSON; любой другой путь к файлу отклоняется.--bare,--safe-mode,--allowedTools,--permission-prompt-toolи--permission-promptsтоже передаёт только CanvasTTY. - Нет ответа за 5 с, ошибка, неверный ответ, отсутствующий секрет или выключенный, удалённый либо переставший быть доверенным плагин отклоняют запуск, и причина видна в окне. Агент никогда не запускается без выбранного вклада. Восстановленное окно с недоступным плагином возвращается остановленным с этой причиной и сохраняет запись, пока плагин не вернётся или окно не закроют.
- Обычный терминал параметров запуска не принимает.
Профили запуска. profile — это normal (по умолчанию), yolo или auto. auto есть только у агентов, чей CLI имеет собственный авторежим, проверенный по --help каждого CLI: Codex --approve-for-me (его собственная проверка в песочнице workspace-write), Claude Code --permission-mode auto с его песочницей ({"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}} сливается в единственный --settings) и Grok --permission-mode auto (на песочницу Grok не полагаемся). Базовая защита и сервисы решений отвечают перед ним (Codex, Claude Code). Если хотя бы один вклад ответил thirdPartyModel: true, auto превращается в режим «только правки» того же CLI в той же песочнице (Codex --sandbox workspace-write --ask-for-approval on-request, Claude Code и Grok --permission-mode acceptEdits): собственной проверкой была бы та же модель, а классификатор слабой модели — не граница безопасности.
Доверенная папка. Субагент на этом компьютере, чья папка — та, что человек выбрал для его агента верхнего уровня, или внутри неё, получает "trustedFolder": реальный путь этой папки. CanvasTTY сам отвечает за него на вопрос Codex «Trust this folder?» переопределением -c projects=… только на этот запуск (в ~/.codex ничего не пишется); плагин, который держит собственную папку конфигурации агента (например, CLAUDE_CONFIG_DIR аккаунта), может отметить папку доверенной там. Codex также получает доверие на этот запуск к хукам, которые добавляет сам CanvasTTY (-c hooks.state=…), и не останавливается на «Hooks need review»; хуки проекта или самого человека по-прежнему спрашивают. Плагины не могут передавать -c hooks….
Политики запуска. С "policy": true сервис спрашивают ещё и перед каждым запуском агентов, к которым он относится (создание, перезапуск, восстановление), где человек его не выбрал, с "chosen": false и пустыми options. Такой ответ может быть только null или refuse; всё остальное, нет ответа за 5 с или ошибка — отказ в запуске, так что политика никогда не пропускает запуск из-за сбоя. Каждый canvastty.launch.prepare несёт и "environment": { pluginId, kind } среды окна или null на этом компьютере. Политика без fields в запускателе не показывается. Отзыв доверия к нативному коду плагина убирает его политику.
"launch": { "policy": true, "fields": [] }Полные примеры: examples/plugins/launch-env (параметры) и examples/plugins/yolo-guard (политика, которая не пускает YOLO вне среды).
Среда — это место, где работает окно: git worktree, контейнер, удалённый хост. Плагин может перечислить до 8 видов в environments — в одном сервисе или в нескольких (например, по сервису на модуль); каждый вид уникален в плагине, и на него отвечает сервис, который его перечислил. После доверия нативному коду в разделе Дополнительно лаунчера появляется Где запустить (по умолчанию Этот компьютер) с видами, подходящими провайдеру, и их необязательными fields (те же виды и лимиты, что у полей запуска). Пока какой-то вид подходит терминалам, Открыть терминал открывает тот же лаунчер (папка и «Где запустить»), а не терминал сразу.
"permissions": ["environment:provide"],
"services": [{
"id": "worktree", "title": "Git worktree", "entry": "services/worktree.mjs",
"environments": [{
"kind": "worktree", "label": "Git worktree",
"description": "A branch in its own folder",
"appliesTo": ["terminal", "claude"],
"fields": [{ "key": "branch", "label": "Branch", "kind": "text", "default": "", "maxLength": 80 }]
}]
}]CanvasTTY владеет окном, PTY, сохранённой записью и порядком восстановления; сервис отвечает на пять запросов, которые может отправить только хост:
| Запрос | Параметры | Ответ | Лимит |
|---|---|---|---|
canvastty.environment.prepare |
sessionId, kind, provider, cwd, options |
{ ref, label, cwd? } или { refuse: { reason } }. ref — непрозрачный JSON до 4 КБ, хранится с окном; label (80 символов) — бейдж; cwd (существующая абсолютная папка) становится папкой окна |
15 с |
canvastty.environment.wrap |
sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd |
{ command, args, env?, secretEnv?, cwd? } или { refuse } |
5 с |
canvastty.environment.resume |
sessionId, kind, ref |
{ ok: true } или { stopped: { reason } } |
10 с |
canvastty.environment.release |
sessionId, kind, ref, keepData, reason (closed или quit) |
игнорируется | 10 с |
canvastty.environment.describe |
sessionId, kind, ref |
{ label, detail? } для бейджа окна и подсказки |
3 с |
prepareвызывается один раз, при первом запуске окна.wrapвызывается перед каждым запуском (создание, перезапуск, восстановление) и превращает то, что хост запустил бы, в то, что работает внутри среды:ssh -tt host …,docker exec -it …или та же программа в другой папке. PTY по-прежнему создаёт хост через node-pty, поэтому прокрутка, статус и оркестрация работают как раньше.- Ответ
wrapпроверяется:command— абсолютный путь к исполняемому файлу или простое имя программы, которое хост находит вPATH; командная строка, относительный путь или синтаксис оболочки отклоняются, ничего не запускается через оболочку.args— массив (256 элементов, до 8 КБ, без NUL).envиsecretEnvподчиняются правилам launch contributors: зарезервированные имена и имена, которые CanvasTTY или параметр запуска уже задают для этого запуска, отклоняются. ЗначенияsecretEnvберутся из собственных секретов плагина (нужноsecrets) и маскируются так же, как секреты запуска. wrapполучает переменные самого запуска (от CanvasTTY и выбранных параметров запуска) без зарезервированных имёнCANVASTTY_*и без значений секретов;secretEnvNamesперечисляет имена, значения которых процесс получит от хоста, чтобы обёртка могла пробросить их по имени (docker exec -e NAME).- При восстановлении сначала возобновляются все сохранённые среды, затем запускаются родители, потом дочерние окна. Если плагин выключен, удалён или не доверен либо
resumeответилstopped, окно возвращается остановленным с причиной и сохраняет запись; «Перезапуск» снова вызываетresume. Окно никогда не запускается локально вместо среды, а таймаут или ошибка отклоняют запуск без запасного варианта. - Пока
prepareне выполнен, сохранённая запись окна хранит выбор из окна запуска (плагин, вид, параметры) вместо ref. Если приложение закрылось во времяprepare, окно возвращается остановленным с этой причиной, и ничего не готовится и не запускается, пока человек его не перезапустит; перезапуск готовит среду заново с теми же параметрами. Неудачныйprepareсохраняется так же. Ответ наprepare, пришедший после закрытия или перезапуска окна или после начала выхода из приложения, не используется и не сохраняется: хост сразу вызываетreleaseсkeepData: falseиreason: "closed", если ещё работает. - При закрытии окна в среде один раз спрашивается «Сохранить данные среды?», затем вызывается
releaseс ответом. Выход из приложения ничего не освобождает (среда вернётся вместе с окном); если сохранение выключено, выход вызываетreleaseсkeepData: trueиreason: "quit", чтобы остановить вычисления. Плагин не ведёт своего списка сессий и не содержит логики восстановления.
Полный пример: examples/plugins/env-worktree: prepare выполняет git worktree add в папке внутри каталога данных плагина, wrap задаёт папку, resume проверяет, что она существует, describe показывает текущую ветку, release удаляет worktree (и созданную им ветку), если вы не решили её сохранить.
Перед тем как локальный агент выполнит shell-команду или запись файла, CanvasTTY может спросить плагин: запретить, спросить человека или разрешить. Один сервис плагина может объявить decide:
"permissions": ["decision:provide"],
"services": [{
"id": "guard", "title": "rm -rf guard", "entry": "services/guard.mjs",
"decide": { "events": ["pre-tool"], "appliesTo": ["claude", "codex"], "timeoutMs": 3000 }
}]pre-tool — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук PreToolUse, у OpenCode через плагин CanvasTTY для OpenCode (tool.execute.before). appliesTo ограничивает агентов; без него — все четыре. Хост отправляет canvastty.decide (только хост) и ждёт не больше timeoutMs (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как budgetMs:
interface DecisionRequest {
event: "pre-tool";
sessionId: string; provider: string; role: "agent" | "orchestrator" | "subagent";
cwd: string; // рабочая папка окна
agentCwd: string | null; // текущая папка агента, если CLI её сообщает
tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] };
input: unknown; // ввод инструмента как есть; null, если больше 40 КБ (truncated)
truncated: boolean;
budgetMs: number; // сколько CanvasTTY ждёт этот ответ
}
// ответ: { verdict: "deny" | "ask" | "allow", reason?: string } или null — нет мненияКак объединяются ответы, по порядку:
- Сначала работает базовая защита (ниже); её запрет окончательный, плагины не спрашиваются.
- Побеждает любой
denyплагина. Модель читаетCanvasTTY plugin "<name>" blocked this tool call (<reason>), поэтому пишите в причине, что сделать вместо этого. - Иначе любой
ask: Claude Code спрашивает человека об этом вызове при любом режиме разрешений. Таймаут, ошибка, остановленный сервис или нечитаемый ответ считаютсяask, никогда не разрешением. - Иначе
allowучитывается только от плагина, которому человек это доверил: второе подтверждение Может разрешать действия агентов под плагином в Настройки → Агенты → Нативный код расширений, снимается вместе с доверием к нативному коду. Тогда Claude Code выполняет вызов без своего вопроса, а на вопрос OpenCode об этом вызове отвечаетсяonce. Разрешение никогда не действует для ввода, который был слишком велик, чтобы отправить его целиком. - Иначе ничего: агент продолжает так же, как без CanvasTTY.
Codex и Qwen Code принимают от этого хука только запрет: для них ask и allow оставляют решение собственному режиму разрешений CLI. Удалённые и контейнерные сессии не покрываются (их хук не достаёт до этого компьютера). Хук ставится агентам, запущенным, пока включена базовая защита или применяется плагин решений, поэтому доверенный позже плагин действует только для новых окон. CLI выполняет вызов, если его хук упал, так что это страховка, а не песочница.
Полный пример: examples/plugins/deny-rm: запрещает rm -rf чего-либо на верхнем уровне рабочей папки (rm -rf *, rm -rf src) и не имеет мнения обо всём остальном. Он объявляет timeoutMs: 5000, чтобы показать поле; отвечает сразу.
Сервис может предложить агентам до 16 tools. Они появляются в MCP-сервере canvastty_agents как <pluginId>__<name> (точки в id плагина становятся _: com.example.tools + lookup — это com_example_tools__lookup; Anthropic и OpenAI допускают в именах инструментов только буквы, цифры, _ и -, не длиннее 64 символов, а у более длинного имени остаётся начало id и короткий хеш) рядом с собственными инструментами оркестрации CanvasTTY и считаются инструментами самого CanvasTTY: базовая защита их не проверяет (она проверяет только shell и запись файлов).
"permissions": ["tools:agents"],
"services": [{
"id": "collect", "title": "Diff stat", "entry": "services/collect.mjs",
"tools": [{
"name": "diffstat",
"description": "git diff --stat вашей папки или папки одного из ваших субагентов.",
"inputSchema": { "type": "object", "properties": { "sessionId": { "type": "string" } }, "additionalProperties": false },
"roles": ["orchestrator"]
}]
}]name—[a-z][a-z0-9_]{0,39}, уникальное в плагине;inputSchema— JSON Schema сtype: "object"на верхнем уровне (до 8 КБ);roles—orchestrator,agentи/илиsubagent.- Инструмент видят только сессии с указанной ролью и только пока нативному коду плагина доверяют и сервис работает. Список читается при запуске агента, поэтому плагин, которому доверились позже, появится в новых окнах. Оркестраторы получают мост как раньше; окно
agentилиsubagentполучает его, только если какой-то инструмент плагина указывает его роль, и видит тогда только инструменты плагинов, никогда — основные инструменты оркестрации. Инструменты плагинов доступны Claude Code, Codex, Qwen Code и OpenCode; Kimi и Hermes используют один файл настроек на все окна и получают только основные инструменты. - Вызов приходит сервису как
canvastty.tools.call(только от хоста) с{ tool, callerSessionId, caller, input }, гдеcaller— сводка вызывающего окна (та же форма, что в событиях сессий). Хост сначала проверяетinput: объект, обязательные ключи, типы свойств верхнего уровня, отсутствие лишних ключей приadditionalProperties: false; более глубокие проверки — дело плагина. - Ответ —
{ content, isError? }:content— текст или любой JSON (отправляется как JSON-текст). Ответ маскируется реестром секретов и обрезается до 32 тыс. символов; отсутствие ответа за 15 с, ошибка или остановленный сервис дают агенту результат-ошибку и ничего больше. Хост ручается только за id вызывающего: инструмент, который работает с другими сессиями, должен проверять их сам (пример принимает только собственных субагентов вызывающего).
Сервис с sessions:events вызывает sessions.subscribe { ownedOnly? } (заново после каждого запуска). Ответ содержит открытые окна; дальше хост присылает уведомления canvastty.sessions.event:
interface PluginSessionEvent {
type: "created" | "restored" | "status" | "exited" | "closed";
owned: boolean; // окно запустил этот плагин
session: {
id: string; provider: string; role: "agent" | "orchestrator" | "subagent"; parentSessionId?: string;
title: string; status: string; exitCode: number | null; startedAt: number;
cwd: string; // папка, которую выбрал человек
workingDirectory: string; // где окно работает на самом деле (среда worktree его переносит)
environment?: { pluginId: string; kind: string; label: string; ref: unknown };
};
screen?: string; // только с sessions:read-screen, в status и exited
}События содержат только метаданные. С sessions:read-screen (в тексте согласия сказано, что это личные данные) события status и exited добавляют последние 4000 символов вывода окна обычным текстом, замаскированным реестром секретов. sessions.list возвращает те же сводки по запросу.
Управление устроено как в шлюзе agent-control: сервис — один контроллер и владеет только тем, что создал. Владение сохраняется в записи сессии окна, поэтому восстановленное окно по-прежнему принадлежит плагину, который его запустил.
| Запрос | Условие | Действие |
|---|---|---|
sessions.create { provider, cwd, profile?, title?, launchOptions?, environment? } |
sessions:launch |
Запускает окно agent через обычный конвейер запуска (включая опции запуска и среды; отказ показывается на окне). Окно видимо и никогда не забирает фокус. Не больше 16 на плагин. Ответ { sessionId } |
sessions.send { sessionId, text, submit? } |
sessions:control |
Вводит текст (с Enter, если не submit: false) в окно, запущенное этим плагином. Окно, чей запуск ещё готовят плагины, получает его, когда запуск начался; sent равен false, если запуск не состоялся (текст отбрасывается) |
sessions.stop { sessionId } |
sessions:control |
Закрывает окно, запущенное этим плагином; данные его среды сохраняются |
Чужой или неизвестный id получает одну и ту же ошибку, поэтому плагин не может прощупывать другие окна. Удаления и чтения экрана через управление нет.
Сервис с cards:decorate может поставить метку на любое окно и объявить до 8 cardActions:
"permissions": ["cards:decorate"],
"services": [{
"id": "collect", "title": "Diff stat", "entry": "services/collect.mjs",
"cardActions": [{ "id": "show-changes", "title": "Show changes", "when": { "environmentKinds": ["worktree"] } }]
}]cards.setBadge{ sessionId, badge: { text, tone?, tooltip? } | null }:textдо 24 символов,tone—neutral(по умолчанию),info,warnилиerror,tooltipдо 200 символов;nullубирает метку плагина. Не больше 4 меток плагинов на окно. Метки — обычный текст, маскируются как текст агентов и исчезают вместе с окном или при снятии доверия к плагину.- Действие появляется в меню параметров окна на каждом окне, которое подходит под
when:providers,environmentKinds(среда любого плагина; окно без среды никогда не подходит) иroles, каждое необязательно; должны совпасть все указанные. Выбор отправляетcanvastty.cards.invoke{ actionId, sessionId, session }(только от хоста;session— сводка выше) и ждёт не больше 15 с. Ответ —{ message?, tone? }: сообщение (обычный текст, до 2000 символов, замаскированный) показывается всплывающим уведомлением на окне. Таймаут или ошибка показывают уведомление об ошибке. - Никакого HTML: метки, заголовки и сообщения выводятся как текст.
Полный пример — examples/plugins/collect-demo: действие Show changes на окнах в среде worktree (из env-worktree) показывает git diff --stat worktree и ставит метку «N changed», а инструмент collect-demo__diffstat даёт оркестраторам то же для своей папки или папки субагента, о котором плагин узнаёт из событий сессий.
Две части безопасности встроены и не требуют плагина:
- Базовая защита (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и
/tmp— и удаление самой рабочей папки. Собственные папки планов и памяти агента (~/.claude/plans,~/.claude/projects/<project>/memoryи то же внутриCLAUDE_CONFIG_DIRзапуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в/tmp— завести временную папку внутри проекта). - Скрытие секретов: весь текст, который CanvasTTY передаёт от одного агента другому (
observe_agent,get_agent_result,screen,resultи детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значенияsecretEnvзапуска, значения, зарегистрированные сервисом черезredaction.register, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (sk-…, GitHub, Slack, AWS, Google, JWT,Bearer …,"apiKey": "…", приватные ключи PEM, длинные случайные строки). Так же маскируются ответы инструментов плагинов,screenв событиях сессий, метки на окнах и сообщения действий.
host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling.
| Permission | Возможность SDK | Граница данных |
|---|---|---|
storage |
storage.get, storage.set |
Изолированное JSON-хранилище, 64 КБ на плагин |
secrets |
secrets.get, secrets.set, secrets.delete; secrets.get сервиса |
Строковые секреты, зашифрованные через Electron safeStorage; без защищённого хранилища ОС вызов завершается ошибкой. Доверенный сервис читает только секреты своего плагина |
sessions:read |
sessions.list |
Только ID, provider, title, status, startedAt и exitCode |
launch:contribute |
Блок launch сервиса и canvastty.launch.prepare |
Может добавлять переменные окружения, аргументы и файлы агентам, запущенным с его опцией; с policy может отказать любому запуску агента |
environment:provide |
environments сервиса и canvastty.environment.* |
Может создавать место для окон, запущенных в его среде, и менять команду, аргументы, переменные и папку, с которыми они там работают |
decision:provide |
decide сервиса и canvastty.decide |
Видит команды и записи файлов агентов (с их вводом) до выполнения и может запрещать их или спрашивать человека; для разрешения нужно второе подтверждение |
tools:agents |
tools сервиса и canvastty.tools.call |
Предлагает инструменты агентам указанных ролей; получает их аргументы и сводку вызывающего окна |
sessions:events |
sessions.subscribe, sessions.list |
Метаданные окон: провайдер, роль, родитель, заголовок, статус, папки, ref среды; без текста экрана |
sessions:read-screen |
screen в событиях status и exited |
Конец вывода каждого окна (замаскированный): личные данные |
sessions:launch |
sessions.create |
Запускает видимые окна агентов через обычный запуск |
sessions:control |
sessions.send, sessions.stop |
Вводит текст и закрывает только окна, запущенные плагином |
cards:decorate |
cards.setBadge, cardActions сервиса, canvastty.cards.invoke |
Текстовые метки на окнах и действия в их меню |
limits:read |
limits.get |
Тот же очищенный LimitsSnapshot, который использует HOME |
launcher:open |
launcher.open |
Открывает штатную Focus Card или запуск терминала; не обходит пользовательский выбор |
external:open |
external.open |
Передаёт ОС только явную HTTP(S)-ссылку |
browser:open |
browser.open |
Открывает только явную HTTP(S)-ссылку во встроенной карточке Browser и её общей browser-сессии, включая localhost |
media:library |
media.* |
Только выбранные пользователем музыкальные папки; абсолютные пути не раскрываются, аудио отдаётся seekable-потоками canvastty-media:// |
playlists:read |
playlists.list, playlists.read |
Читает .m3u, .m3u8 и .pls в разрешённой музыкальной папке, а .json — только в её Playlists/, до 4 МБ на файл |
playlists:write |
playlists.write |
Атомарно записывает плейлист в каталог Playlists/ разрешённой папки, до 4 МБ |
network |
browser fetch |
Разрешает HTTPS и loopback в CSP; учётные данные CanvasTTY не прикрепляются |
Permission не открывает generic IPC. Неизвестные методы и permissions отклоняются.
Подключите host SDK внешним script:
<script src='canvastty-plugin://host/sdk.js'></script>
<script src='./index.js'></script>SDK создаёт window.CanvasTTYPlugin:
const host = window.CanvasTTYPlugin;
host.onContext(({ appearance, contribution }) => {
document.documentElement.dataset.palette = appearance.palette;
document.title = contribution.title;
});
const sessions = await host.request("sessions.list");
await host.storage.set("draft", { text: "Локально для этого плагина" });
const draft = await host.storage.get("draft");
await host.secrets.set("oauth-token", token);
const restoredToken = await host.secrets.get("oauth-token");
await host.request("launcher.open", { provider: "codex" });
await host.canvas.open("notes");
await host.request("window.open", { contributionId: "focus" });
await host.request("browser.open", { url: "http://localhost:9210" });
const library = await host.media.pickLibrary();
if (library) {
const audio = document.querySelector("audio");
const tracks = await host.media.scanLibrary(library.id);
if (audio) audio.src = tracks[0]?.streamUrl ?? "";
const playlists = await host.playlists.list(library.id);
const text = playlists[0] ? await host.playlists.read(library.id, playlists[0].id) : "";
await host.playlists.write(library.id, "favorites.m3u8", text || "#EXTM3U\n");
}Поддержаны host.getContext, storage.*, secrets.*, sessions.list, limits.get, launcher.open, canvas.open, external.open, browser.open, window.open, media.* и playlists.*. canvas.open открывает или фокусирует canvas-app того же плагина и по возможности ставит его рядом с вызывающей карточкой. browser.open завершается только после создания или фокусировки Browser-card workspace и одной навигации; принимаются лишь нормализованные HTTP(S)-URL, а не текст для поиска, file:, data:, javascript:, about: или URL с учётными данными. window.open может открыть только contribution типа window из того же manifest.
Используйте storage для несекретных JSON-настроек, а secrets — только для OAuth-токенов, API-ключей и других учётных данных. Поддерживается до 32 строковых ключей, 16 КБ на значение и 64 КБ на плагин. Секреты удаляются при uninstall и никогда не сохраняются в plaintext; если ОС не предоставляет защищённое шифрование, вызов явно завершается ошибкой.
Разрешения музыкальных библиотек сохраняются между перезапусками, перечисляются и отзываются только владеющим плагином. Сканирование пропускает symlink и возвращает относительные пути, метаданные и непрозрачные stream URL вместо абсолютного корня библиотеки. При удалении плагина все его разрешения на папки отзываются. Содержимое плейлиста возвращается как записано и не привязано к формату: плеер может использовать стандартные M3U/PLS или собственную JSON-схему; импортированный плейлист сам может содержать абсолютные пути.
Локальному плееру обычно нужны:
"permissions": ["storage", "media:library", "playlists:read", "playlists:write"]Добавляйте network только для удалённых каталогов, радио, обложек или стримов; external:open — только для явных ссылок, открываемых в системном браузере; а browser:open — только для явных HTTP(S)-страниц, предназначенных для общей встроенной browser-сессии CanvasTTY. storage предназначен для настроек плеера, избранного, очереди и небольших JSON-метаданных; сами аудиофайлы остаются в выбранных пользователем папках.
| Вызов SDK | Результат и назначение |
|---|---|
host.media.pickLibrary() |
Открывает системный выбор каталога и сохраняет разрешение; возвращает { id, name } или null при отмене |
host.media.listLibraries() |
Восстанавливает разрешённые этому плагину библиотеки после перезапуска, не раскрывая абсолютные пути |
host.media.scanLibrary(libraryId) |
Рекурсивно возвращает до 20 000 поддерживаемых треков: ID, имя, относительный путь, размер, MIME type и streamUrl |
host.media.revokeLibrary(libraryId) |
Отзывает разрешение этого плагина на выбранную папку |
host.playlists.list(libraryId) |
Перечисляет до 2 000 доступных плейлистов внутри разрешённой библиотеки |
host.playlists.read(libraryId, playlistId) |
Возвращает исходный UTF-8 текст плейлиста размером до 4 МБ |
host.playlists.write(libraryId, name, content) |
Атомарно записывает .m3u, .m3u8, .pls или .json в каталог Playlists/ библиотеки, до 4 МБ |
Сканируются аудиофайлы .aac, .flac, .m4a, .mp3, .oga, .ogg, .opus, .wav и .webm. track.streamUrl можно сразу назначить элементу <audio>: host поддерживает byte-range responses, поэтому определение длительности и перемотка работают. Плагин с media:library также может выполнить fetch(track.streamUrl), если байты нужны для разбора метаданных в браузере. Полные overloads методов и result interfaces находятся в plugin-api.d.ts.
Рекомендуемый запуск: вызвать listLibraries(), предложить pickLibrary() только если разрешённых папок ещё нет, просканировать выбранную библиотеку, восстановить очередь и настройки из storage, затем получить и разобрать плейлисты. Отозванную или перемещённую папку показывайте явным состоянием «недоступно» и предложите выбрать её заново.
Context сообщает текущие locale и palette CanvasTTY. Локализация и внутренние стили — ответственность плагина. Плагин не должен выдумывать progress, sessions, status, limits или telemetry.
- Опубликуйте готовый статический пакет в корне публичного GitHub-репозитория.
- Откройте Настройки → Плагины.
- Вставьте
https://github.com/owner/repositoryи нажмите Проверить. - Прочитайте manifest и permissions, затем подтвердите Установить.
- В том же разделе плагин можно включить, выключить или удалить. Его HOME widgets добавляются и удаляются рядом со встроенными виджетами в разделе Оформление → Состав HOME. Если manifest объявляет
settingsContribution, карточка плагина также показывает отдельное действие Настройки. - Откройте Настройки → Оформление → Состав HOME и нажмите Редактировать HOME, чтобы двигать плитки, менять их размер или тянуть правый нижний угол границы HOME. Плитка Settings сохраняется как аварийная точка входа; остальные системные и plugin tiles опциональны.
Установщик намеренно не принимает приватные репозитории, ссылки GitHub вида /tree/branch/subdirectory и репозитории, которым нужен build. Публикуйте готовый пакет в корне.
Просмотр и поиск в витрине работают без входа, через публичный поиск GitHub. Вход необязателен и только повышает лимиты поиска GitHub; когда анонимный лимит исчерпан, CanvasTTY показывает, когда можно повторить. Опциональный вход в витрину использует GitHub OAuth Device Flow. Сопровождающий сборку может зарегистрировать OAuth App и включить Device Flow, затем сохранить его публичный client ID в repository variable GitHub Actions CANVASTTY_GITHUB_CLIENT_ID. Официальная сборка встраивает это значение, когда оно настроено; для локальной сборки подходят GITHUB_OAUTH_CLIENT_ID и CANVASTTY_GITHUB_CLIENT_ID, а при запуске любая из них может переопределить встроенный ID. Client secret в приложение не встраивается и не требуется. По умолчанию вход открывает GitHub во встроенном Browser CanvasTTY, а системный браузер остаётся явным fallback. Без client ID интерфейс прямо сообщает, что OAuth недоступен, но проверка и установка по прямой ссылке продолжают работать. Отключение удаляет локальную зашифрованную сессию; при необходимости отдельно отзовите доступ в настройках приложений GitHub.
- Используйте только structured host data и явные loading/unavailable/error states.
- Запрашивайте минимальный набор permissions.
- Держите scripts во внешних файлах; inline script не выполнится.
- Не рассчитывайте на Node.js, filesystem paths, PTY history, provider tokens или parent DOM.
- Проверяйте HOME widget в минимальном заявленном размере и при zoom канваса.
- Проверяйте canvas app в semantic summary ниже
0.5×. - Проверяйте одинаковые SDK-вызовы внутри iframe и отдельного окна.
- При изменении host/example запускайте
npm test,npm run typecheck,npm run build.