Плагин для opencode, повторяющий поведение «auto mode» в Claude Code: когда агент запрашивает пермишен (bash-команда, правка файла и т.д.), решение allow/deny вместо человека принимает вторая, дешёвая модель.
A plugin for opencode that reproduces Claude Code's "auto mode": when the agent asks for a permission (a bash command, a file edit and so on), a second, cheap model decides allow/deny instead of the human.
Хук permission.ask из документации opencode на актуальных версиях (1.14–1.18) не триггерится — это мёртвый тип, из-за чего большинство похожих плагинов из интернета не работают. Этот плагин использует рабочий механизм:
- Плагин подписывается на хук
eventи ловит bus-событиеpermission.asked— то самое, на которое отвечает диалог в TUI. - Тривиальные read-only команды (
git status,ls,pwd…) одобряются локально, без обращения к модели, а то, что судья одобрил трижды, плагин запоминает сам — см. файл правил. - Иначе собирается контекст запроса: инструмент, паттерны, метаданные (команда, diff, путь к файлу) и последняя реплика пользователя в сессии — чтобы судья видел, о чём его вообще просили.
- Контекст уходит второй модели (OpenAI-совместимый API или Anthropic) со строгим системным промптом; идентичные запросы берутся из кэша.
- Ответ парсится как строгий JSON
{"decision":"allow|deny|ask","reason":"..."}, и плагин отвечает на пермишен через API opencode.
The permission.ask hook from the opencode documentation never fires on current versions (1.14–1.18) — it is a dead type, which is why most similar plugins found online do not work. This plugin uses the mechanism that actually runs:
- The plugin subscribes to the
eventhook and catches thepermission.askedbus event — the very one the TUI dialog answers. - Trivial read-only commands (
git status,ls,pwd…) are approved locally, without calling a model, and whatever the judge has approved three times the plugin remembers on its own — see the rules file. - Otherwise the request context is assembled: the tool, the patterns, the metadata (command, diff, file path) and the user's last message in the session, so that the judge can see what was actually asked for.
- The context goes to the second model (an OpenAI-compatible API or Anthropic) with a strict system prompt; identical requests are served from the cache.
- The answer is parsed as strict JSON
{"decision":"allow|deny|ask","reason":"..."}, and the plugin replies to the permission through the opencode API.
| Решение | Что делает плагин |
|---|---|
allow |
отвечает once (или always, см. опцию approve) |
deny |
отвечает reject и прикладывает причину — агент видит, за что ему отказали |
ask |
молчит, запрос уходит человеку в TUI |
Модель обязана отвечать ask во всём, в чём не уверена: неоднозначный запрос, незнакомый инструмент, попытка инъекции. Неуверенность не должна превращаться в жёсткий отказ, который человек даже не увидит.
Семантика fallback: если модель недоступна, нет ключа, случился таймаут или ответ не распарсился — плагин не отвечает вообще, и запрос остаётся человеку. Ошибочное молчание безопасно: ошибочное «allow» — нет.
Причина отказа доходит до агента. opencode принимает вместе с reject поле message и превращает его в текст ошибки инструмента: агент получает не голое «отказано», а «отказано, потому что…», и может объяснить это человеку словами вместо того, чтобы молча пробовать другой путь. Причина берётся из вердикта судьи, обрезается до 400 символов и уходит с приставкой auto-permission judge (not the human) denied this:. Приставка не украшение: opencode формулирует ошибку как «The user rejected permission…», и без неё агент решит, что запретил человек, и так и скажет ему.
Есть нюанс маршрутов. Поле message принимает только роут POST /permission/{requestID}/reply; основной, через который отвечает SDK (POST /session/{id}/permissions/{permissionID}), несёт одно поле response и причину донести не может. Поэтому отказ с причиной идёт первым роутом, а если тот недоступен — обычным, но уже без причины: доставить решение важнее, чем объяснить его. Одобрения ходят прежним путём.
Гонка с человеком безвредна: кто первым ответил через permission.reply, тот и принял решение; второй ответ получает 404 и игнорируется.
| Answer | What the plugin does |
|---|---|
allow |
replies once (or always, see the approve option) |
deny |
replies reject with the reason attached — the agent sees what it was refused for |
ask |
stays silent, the request goes to the human in the TUI |
The model is required to answer ask whenever it is not sure: an ambiguous request, an unfamiliar tool, an injection attempt. Uncertainty must not turn into a hard refusal that the human never even sees.
Fallback semantics: if the model is unreachable, the key is missing, the request times out or the answer does not parse — the plugin does not reply at all, and the request stays with the human. Silence by mistake is safe; "allow" by mistake is not.
The reason for a refusal reaches the agent. opencode accepts a message field alongside reject and turns it into the tool's error text: the agent gets "refused because…" rather than a bare "refused", and can put that to the human in words instead of silently trying another way. The reason comes from the judge's verdict, is truncated to 400 characters and is sent with the prefix auto-permission judge (not the human) denied this:. The prefix is not decoration: opencode phrases the error as "The user rejected permission…", and without it the agent concludes the human refused, and tells them so.
There is a routing catch. The message field is accepted only by POST /permission/{requestID}/reply; the main route the SDK answers through (POST /session/{id}/permissions/{permissionID}) carries a single response field and cannot convey a reason. So a refusal with a reason goes through the first route, and if that one is unreachable, through the ordinary one without the reason: delivering the decision matters more than explaining it. Approvals take the same path as before.
Racing the human is harmless: whoever answers through permission.reply first makes the decision, and the second answer gets a 404 and is ignored.
Метаданные пермишена (текст команды, diff, пути) — это недоверенные данные: их может контролировать содержимое файлов, которые читает агент. Поэтому они передаются судье в блоке <untrusted_data>…</untrusted_data>, закрывающий тег внутри экранируется, а системный промпт прямо запрещает исполнять инструкции из этого блока и требует отвечать deny или ask на любую такую попытку.
The permission metadata (the command text, the diff, the paths) is untrusted data: it can be controlled by the content of the files the agent reads. It is therefore passed to the judge inside an <untrusted_data>…</untrusted_data> block, any closing tag inside it is escaped, and the system prompt explicitly forbids following instructions from that block and requires answering deny or ask to any such attempt.
Без неё судья видит голую команду без всякой цели. cat ~/.config/app/config.json выглядит как чтение чужого конфига вне проекта — и получает deny, даже если пользователь сам только что попросил этот файл показать. Поэтому плагин читает последнее сообщение пользователя в сессии через client.session.messages и кладёт его отдельным блоком <user_request>, до недоверенных данных.
Блоки разного доверия: <user_request> — слова человека, полученные от рантайма, а не от агента и не из файла; <untrusted_data> — то, что мог написать атакующий. Системный промпт велит взвешивать действие относительно запроса, но запрещает оправдывать запросом деструктивные и эксфильтрирующие действия. Если история сессии недоступна, плагин просто решает без неё.
Without it the judge sees a bare command with no purpose behind it. cat ~/.config/app/config.json looks like reading someone else's config outside the project — and gets a deny, even when the user has just asked for that very file. So the plugin reads the user's last message in the session through client.session.messages and puts it in a separate <user_request> block, ahead of the untrusted data.
The two blocks carry different trust: <user_request> holds the human's own words, obtained from the runtime rather than from the agent or from a file; <untrusted_data> holds whatever an attacker may have written. The system prompt tells the judge to weigh the action against the request, but forbids using the request to justify destructive or exfiltrating actions. If the session history is unavailable, the plugin simply decides without it.
Плагин ведёт один файл правил на проект и решает по нему, не тратя ни времени, ни токенов. Файл лежит вне репозитория — ~/.local/share/opencode-auto-permission/<путь-проекта>.json (уважается XDG_DATA_HOME, путь переопределяется опцией rulesFile). Правила не должны путешествовать вместе с кодом.
{
"version": 1,
"preset": ["pwd *", "ls *", "cat *", "git status *"],
"learned": [{ "command": "npm run typecheck", "approvals": 3, "lastSeen": "2026-08-26T12:00:00.000Z" }],
"pending": { "npm test": 2 }
}Синтаксис глобов — тот же, что у нативного permission.bash в opencode.json: * это любой хвост, ? — один символ, а паттерн, кончающийся на пробел со звёздочкой, матчит и команду без аргументов ("git status *" покрывает голое git status). Любую строку отсюда можно скопировать в конфиг opencode и обратно.
preset создаётся при первом запуске из встроенного списка: pwd, ls, cat, head, tail, wc, grep, rg, find, which, file, stat, tree, du, df, date, whoami, hostname, uname и git status/diff/log/branch/show/remote/blame/rev-parse. Дальше файл принадлежит вам: плагин его не перезаписывает, стёртое правило не возвращается.
К preset применяются те же строгие проверки, что и раньше, — глоб их не отменяет:
- только пермишен
bash; - в команде нет шелл-метасимволов (
;&|<>$(){}[]*?~#'", обратная кавычка, обратный слэш, перевод строки); - ни один аргумент не начинается с
/и не содержит..; - ни один аргумент не входит в список разрушительных флагов (
-delete,-exec,-execdir,-ok,-fprint…), чтобыfind . -deleteне проехал как «чтение».
Обратите внимание на границу: запрет абсолютных путей и .. означает, что чтение идёт только внутри проекта — cat /etc/shadow и cat ~/.ssh/id_rsa уходят судье. Но cat .env внутри репозитория пресет пропустит. Если вы держите секреты в рабочем дереве, уберите правила cat *, head *, tail * и grep * из своего файла.
learned — то, что плагин запомнил сам. Команда, которую судья одобрил learnAfter раз подряд (по умолчанию три), переезжает из pending в learned и дальше решается мгновенно. Правила тут жёсткие:
- запоминается точная строка команды (схлопнув лишние пробелы), без звёздочек. Обобщать до глоба имеет право только человек, руками: автоматика не отличит
git push origin featureотgit push origin main; - запоминается только
allow. Отказ не запоминается никогда — иначе один ошибочныйdenyзалипнет навсегда и останется невидимым; - только
bash.
pending — счётчики недозревших команд. Они живут в файле, а не в памяти, иначе после каждого перезапуска opencode счёт начинался бы заново и порог не брался бы никогда. learnAfter: 0 выключает обучение совсем.
Если файл повреждён или в нём чужая version, плагин его не трогает и не перезаписывает: он просто работает без правил, отправляя всё судье, и пишет причину в debugLog.
The plugin keeps one rules file per project and decides from it, spending neither time nor tokens. The file lives outside the repository — ~/.local/share/opencode-auto-permission/<project-path>.json (XDG_DATA_HOME is honoured, the path is overridden by the rulesFile option). Rules should not travel together with the code.
The glob syntax is the same as that of the native permission.bash in opencode.json: * is any tail, ? is a single character, and a pattern ending in a space followed by a star also matches the command without arguments ("git status *" covers a bare git status). Any line from this file can be pasted into the opencode config and back.
preset is written on the first run from the built-in list: pwd, ls, cat, head, tail, wc, grep, rg, find, which, file, stat, tree, du, df, date, whoami, hostname, uname and git status/diff/log/branch/show/remote/blame/rev-parse. After that the file is yours: the plugin never rewrites it, and a rule you delete stays deleted.
The same strict checks as before apply to preset — a glob does not lift them:
- the
bashpermission only; - no shell metacharacters in the command (
;&|<>$(){}[]*?~#'", backtick, backslash, newline); - no argument starts with
/or contains..; - no argument is one of the destructive flags (
-delete,-exec,-execdir,-ok,-fprint…), so thatfind . -deletedoes not slip through as "reading".
Mind where that line falls: forbidding absolute paths and .. means reads stay inside the project only — cat /etc/shadow and cat ~/.ssh/id_rsa go to the judge. But cat .env inside the repository does pass the preset. If you keep secrets in the working tree, remove the cat *, head *, tail * and grep * rules from your file.
learned is what the plugin has picked up by itself. A command the judge has allowed learnAfter times in a row (three by default) moves from pending to learned and is answered instantly from then on. The rules here are strict:
- the exact command string is stored (with repeated whitespace collapsed), never a star. Generalizing to a glob is a human's job, done by hand: a machine cannot tell
git push origin featurefromgit push origin main; - only
allowis stored. A refusal is never remembered — otherwise one mistakendenywould stick forever and stay invisible; bashonly.
pending holds the counters of commands that have not matured yet. They live in the file rather than in memory; kept in memory, the count would restart with every opencode restart and the threshold would never be reached. learnAfter: 0 turns learning off entirely.
If the file is corrupted or carries a version this build does not know, the plugin leaves it alone and does not rewrite it: it simply runs without rules, sending everything to the judge, and writes the reason to debugLog.
Модель судьи можно не настраивать вовсе. Без опции model плагин судит той же моделью, которой работает opencode в этой сессии: провайдера и модель он берёт из последнего ответа ассистента (providerID, modelID), а как в этого провайдера ходить — из GET /api/provider/{providerID}. Смените модель в TUI — сменится и судья, отдельной настройки держать не нужно.
Секреты opencode по HTTP не отдаёт, и это правильно: GET /config возвращает baseURL и заголовки пустыми строками. Зато в конфиге они обычно записаны шаблонами — "{env:AGG_LITELLM_URL}", "{env:AGG_LITELLM_TOKEN}", — и такой шаблон плагин разрешает сам через process.env: он исполняется внутри процесса opencode, так что переменные ему видны. Заголовки провайдера уходят судье как есть, поэтому нестандартная авторизация вроде x-litellm-api-key работает без единой опции.
Явная настройка перекрывает всё: задан model — работают provider, baseUrl, apiKey/apiEnv, как раньше. Это по-прежнему нужно, чтобы посадить судьёй модель подешевле основной.
Чего плагин не умеет: провайдеры, авторизованные через opencode auth login. Их ключи лежат в отдельном хранилище, а не в конфиге, и по API не выдаются — GET /provider/auth отвечает только тем, каким способом провайдер авторизован. В таком случае плагин молчит, запрос уходит человеку, в аудит-логе остаётся status: no_key, а причина пишется в debugLog. Молчание при неопределённости важнее удобства — как и везде в этом плагине.
The judge model needs no configuration at all. With no model option the plugin judges with the same model opencode is running in this session: the provider and the model come from the last assistant reply (providerID, modelID), and how to reach that provider comes from GET /api/provider/{providerID}. Switch models in the TUI and the judge switches too — there is no second setting to keep in sync.
opencode does not hand out secrets over HTTP, and rightly so: GET /config returns baseURL and headers as empty strings. But in the config they are usually written as templates — "{env:AGG_LITELLM_URL}", "{env:AGG_LITELLM_TOKEN}" — and the plugin resolves such a template itself through process.env: it runs inside the opencode process, so those variables are visible to it. The provider's headers are passed to the judge as they are, which is why non-standard authorization such as x-litellm-api-key works with no options at all.
An explicit setting overrides everything: set model and provider, baseUrl, apiKey/apiEnv work exactly as before. That is still how you seat a cheaper model as the judge.
What the plugin cannot do: providers authorized through opencode auth login. Their keys live in a separate store rather than in the config and are not served over the API — GET /provider/auth only reports how a provider is authorized. In that case the plugin stays silent, the request goes to the human, the audit log keeps status: no_key, and the reason goes to debugLog. Silence under uncertainty beats convenience — as everywhere else in this plugin.
Одинаковые запросы (тот же пермишен + паттерны + метаданные) решаются один раз за сессию — повтор берётся из кэша. Решения ask не кэшируются: неоднозначное каждый раз смотрится заново.
С опцией auditLog каждое решение дописывается строкой JSONL — включая случаи, когда плагин промолчал:
{"time":"2026-08-26T09:00:00.000Z","sessionID":"ses_1","permissionID":"prm_1","permission":"bash","decision":"deny","reason":"rm -rf on a broad path","status":"ok","source":"llm","model":"gpt-4o-mini","command":"rm -rf /"}source — откуда решение: llm, cache, preset или learned. decision: "none" означает, что запрос ушёл человеку, а status объясняет почему:
status |
Что случилось | Что делать |
|---|---|---|
ok |
вердикт получен | — |
no_key |
нет ключа к судье | проверить apiKey/apiEnv и окружение процесса opencode |
http_error |
API судьи ответил ошибкой (код в detail) |
смотреть на провайдера |
network_error |
сеть или таймаут (текст в detail) |
поднять timeoutMs, проверить прокси |
truncated |
ответ обрезан по лимиту токенов | поднять maxDecisionTokens или урезать maxMetadataChars |
unparsed |
судья написал прозу вместо JSON | сменить модель или править systemPrompt |
Identical requests (the same permission plus patterns plus metadata) are decided once per session — a repeat is served from the cache. ask decisions are never cached: an ambiguous one is looked at afresh every time.
With the auditLog option every decision is appended as a JSONL line — including the cases where the plugin stayed silent.
source says where the decision came from: llm, cache, preset or learned. decision: "none" means the request went to the human, and status explains why:
status |
What happened | What to do |
|---|---|---|
ok |
a verdict was received | — |
no_key |
no key for the judge | check apiKey/apiEnv and the environment of the opencode process |
http_error |
the judge's API returned an error (code in detail) |
look at the provider |
network_error |
network trouble or a timeout (text in detail) |
raise timeoutMs, check the proxy |
truncated |
the answer was cut off by the token limit | raise maxDecisionTokens or trim maxMetadataChars |
unparsed |
the judge wrote prose instead of JSON | change the model or edit systemPrompt |
В глобальный конфиг ~/.config/opencode/opencode.json:
Into the global config at ~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
["/home/rnds/projects/opencode-auto-permission/src/index.ts", {
"provider": "openai",
"model": "gpt-4o-mini",
"tools": ["bash", "edit"],
"auditLog": "/home/rnds/.local/share/opencode/auto-permission.jsonl"
}]
]
}После изменения конфига перезапустить opencode — конфиг не перечитывается на лету.
Restart opencode after changing the config — it is not re-read on the fly.
Важно: без "*": "ask" плагин не вызывается / Important: without "*": "ask" the plugin is never called
Плагин судит только те действия, по которым opencode поднял запрос пермишена. Если в секции permission нет правила, совпавшего с командой, opencode берёт дефолт allow, разрешает молча, и события permission.asked не будет — плагин при этом загружен, но не участвует.
Поэтому вместе с плагином нужно вернуть ask по умолчанию:
The plugin judges only those actions for which opencode has raised a permission request. If no rule in the permission section matches the command, opencode falls back to allow, permits it silently, and no permission.asked event happens — the plugin is loaded but takes no part.
So along with the plugin you need to bring the default ask back:
"permission": {
"bash": {
"npm test *": "allow",
"*": "ask"
}
}Всё, что закрыто глобами allow, отрабатывает мгновенно и бесплатно; остальное идёт судье. Проверить, что правило сработало, можно по логу сервера: action.action=ask означает, что пермишен поднят, action.action=allow — что плагин в этот раз не при делах.
Everything covered by allow globs runs instantly and for free; the rest goes to the judge. To check that a rule fired, look at the server log: action.action=ask means the permission was raised, action.action=allow means the plugin had nothing to do with it this time.
| Опция | По умолчанию | Описание |
|---|---|---|
provider |
"openai" |
"openai" (любой OpenAI-совместимый API: OpenAI, Groq, OpenRouter, Together, Ollama, litellm…) или "anthropic" |
baseUrl |
https://api.openai.com/v1 / https://api.anthropic.com |
Базовый URL API |
model |
модель текущей сессии | Модель-«судья». Если не задать, плагин берёт ту же модель, которой работает opencode в этой сессии, — см. «Судья по умолчанию» |
apiKey |
— | Ключ. Если не задан, берётся из переменной окружения (см. apiEnv). Нужен только когда судья задан явно через model |
apiEnv |
OPENAI_API_KEY / ANTHROPIC_API_KEY |
Имя переменной окружения с ключом |
tools |
все | Белый список пермишенов, которые авто-отвечаются: bash, edit, webfetch, doom_loop, external_directory |
skipTools |
["doom_loop"] |
Чёрный список (приоритетнее белого) |
approve |
"once" |
Как одобрять: "once" (только этот вызов) или "always" (запомнить до конца сессии) |
timeoutMs |
60000 |
Таймаут запроса к модели, мс. Разброс времени ответа задаётся загрузкой эндпоинта, а не размером задачи: замеры на glm-5-turbo дали от 2,5 до 79 секунд на сопоставимых по объёму запросах. По таймауту плагин молчит и запрос уходит человеку — в аудит-логе это status: network_error |
maxMetadataChars |
8000 |
Обрезка метаданных (diff, команда) в промпте |
sessionContext |
true |
Передавать судье последнюю реплику пользователя из сессии |
maxContextChars |
2000 |
Обрезка этой реплики |
maxDecisionTokens |
16000 |
Потолок ответа судьи. Ответ — JSON на 30-60 токенов, остальное reasoning: замер на glm-5.2 — 820 токенов (758 из них reasoning) на diff в 8000 символов. Реально ограничивает timeoutMs, а не этот потолок, поэтому занижать его смысла нет |
systemPrompt |
встроенный | Свой системный промпт для модели-судьи |
cache |
true |
Кэшировать решения по идентичным запросам в пределах сессии |
cacheMax |
500 |
Максимум записей в кэше |
auditLog |
— | Путь к JSONL-файлу с журналом решений |
rulesFile |
~/.local/share/opencode-auto-permission/<путь-проекта>.json |
Файл правил проекта: пресет, выученное и счётчики |
learnAfter |
3 |
Сколько одобрений судьи подряд нужно команде, чтобы попасть в learned. 0 выключает обучение |
debugLog |
— | Путь к JSONL-файлу с диагностикой самого плагина: ошибки сети, несовместимость параметров, срывы ответа. Без него плагин молчит и в stderr не пишет ничего — терминал делят сервер opencode и TUI. Чтобы понять, как решался конкретный запрос, нужен auditLog, а не это |
| Option | Default | Description |
|---|---|---|
provider |
"openai" |
"openai" (any OpenAI-compatible API: OpenAI, Groq, OpenRouter, Together, Ollama, litellm…) or "anthropic" |
baseUrl |
https://api.openai.com/v1 / https://api.anthropic.com |
Base URL of the API |
model |
the session's own model | The judge model. Left unset, the plugin uses the same model opencode is running in this session — see "The default judge" |
apiKey |
— | The key. If unset, it is taken from an environment variable (see apiEnv). Only needed when the judge is set explicitly through model |
apiEnv |
OPENAI_API_KEY / ANTHROPIC_API_KEY |
Name of the environment variable holding the key |
tools |
all | Allowlist of permissions that get answered automatically: bash, edit, webfetch, doom_loop, external_directory |
skipTools |
["doom_loop"] |
Denylist (takes precedence over the allowlist) |
approve |
"once" |
How to approve: "once" (this call only) or "always" (remembered until the end of the session) |
timeoutMs |
60000 |
Request timeout for the model, ms. The spread in response time comes from endpoint load, not from task size: measurements on glm-5-turbo ranged from 2.5 to 79 seconds on comparable requests. On timeout the plugin stays silent and the request goes to the human — in the audit log that is status: network_error |
maxMetadataChars |
8000 |
Truncation of the metadata (diff, command) in the prompt |
sessionContext |
true |
Pass the user's last message in the session to the judge |
maxContextChars |
2000 |
Truncation of that message |
maxDecisionTokens |
16000 |
Ceiling on the judge's answer. The answer is 30-60 tokens of JSON, the rest is reasoning: a measurement on glm-5.2 gave 820 tokens (758 of them reasoning) on an 8000-character diff. What really constrains you is timeoutMs, not this ceiling, so lowering it buys nothing |
systemPrompt |
built-in | Your own system prompt for the judge model |
cache |
true |
Cache decisions for identical requests within a session |
cacheMax |
500 |
Maximum number of cache entries |
auditLog |
— | Path to a JSONL file with the decision log |
rulesFile |
~/.local/share/opencode-auto-permission/<project-path>.json |
The project's rules file: preset, learned rules and counters |
learnAfter |
3 |
How many judge approvals in a row a command needs to reach learned. 0 turns learning off |
debugLog |
— | Path to a JSONL file with diagnostics of the plugin itself: network errors, parameter incompatibilities, malformed answers. Without it the plugin is silent and writes nothing to stderr — the opencode server and the TUI share one terminal. To understand how a particular request was decided you want auditLog, not this |
Ollama (локальная модель, бесплатно):
Ollama (a local model, free):
["/path/to/src/index.ts", {
"baseUrl": "http://localhost:11434/v1",
"model": "qwen2.5:7b",
"apiKey": "ollama",
"tools": ["bash", "edit"]
}]Groq (быстро и дёшево):
Groq (fast and cheap):
["/path/to/src/index.ts", {
"baseUrl": "https://api.groq.com/openai/v1",
"model": "llama-3.1-8b-instant",
"apiEnv": "GROQ_API_KEY"
}]Anthropic:
["/path/to/src/index.ts", {
"provider": "anthropic",
"model": "claude-haiku-4-5"
}]Диалог пермишена рисует сам opencode, сразу и с активными кнопками, а плагин отвечает через несколько секунд — пока судья думает. Со стороны это неотличимо от «автомод сломался, нужен ручной ответ».
Плагин показывает тост через client.tui.showToast (POST /tui/show-toast) прямо из серверной половины:
- перед обращением к судье —
auto-permission: судья решает, отвечать не нужно; - если судья ответил
askили не ответил вовсе —auto-permission: судья не решил — решение за вами. Вот тут диалог сам не закроется, отвечать действительно вам.
Решения по файлу правил и из кэша мгновенные, тост для них не показывается — иначе он мелькал бы на каждой команде.
The permission dialog is drawn by opencode itself, immediately and with live buttons, while the plugin answers a few seconds later — the judge is still thinking. From the outside that is indistinguishable from "auto mode is broken, answer it by hand".
The plugin shows a toast through client.tui.showToast (POST /tui/show-toast) straight from its server half:
- before going to the judge —
auto-permission: судья решает, отвечать не нужно("the judge is deciding, no answer needed"); - if the judge answered
askor did not answer at all —auto-permission: судья не решил — решение за вами("the judge did not decide, it is up to you"). Here the dialog will not close on its own, and it really is yours to answer.
Decisions from the rules file and from the cache are instant, and no toast is shown for them — otherwise it would flash on every single command.
| Опция | По умолчанию | Описание |
|---|---|---|
notify |
true |
Показывать тосты в TUI |
notifyMessage |
«судья решает…» | Текст тоста ожидания |
notifyStalledMessage |
«решение за вами» | Текст, когда решение вернулось человеку |
notifyDurationMs |
5000 |
Сколько висит тост, мс |
notifyHeartbeatMs |
2000 |
Как часто переотправлять тост, пока он должен висеть; 0 — не переотправлять |
notifyStalledMaxMs |
300000 |
Потолок удержания предупреждения, мс — страховка на случай, если ответ человека не долетел |
| Option | Default | Description |
|---|---|---|
notify |
true |
Show toasts in the TUI |
notifyMessage |
"the judge is deciding…" | Text of the waiting toast |
notifyStalledMessage |
"it is up to you" | Text shown when the decision came back to the human |
notifyDurationMs |
5000 |
How long the toast stays up, ms |
notifyHeartbeatMs |
2000 |
How often to re-send the toast while it should stay up; 0 disables re-sending |
notifyStalledMaxMs |
300000 |
Ceiling on holding the warning, ms — a safeguard in case the human's reply never arrives |
TUI держит ровно один тост: show кладёт его в currentToast, заменяя предыдущий, и заводит таймер на duration (по умолчанию 5000 мс). Спрятать тост по событию нечем — в API нет ни hide, ни идентификатора, и закрыть его руками пользователь тоже не может: у компонента нет ни клавиши, ни клика.
Поэтому «висит, пока идёт работа» делается переотправкой: пока тост должен висеть, плагин каждые notifyHeartbeatMs показывает тот же тост заново — каждый показ сбрасывает таймер. Так тост не исчезает посреди ожидания на медленном судье и не залипает навсегда, если плагин упал.
Оба тоста удерживаются так, но события, снимающие удержание, разные:
- тост ожидания держится, пока плагин ждёт судью, и снимается, как только вердикт получен, — дальше он гаснет сам через
notifyDurationMsили его сразу заменяет предупреждение; - предупреждение держится, пока на пермишен не ответил человек: снимается по событию
permission.repliedс тем же идентификатором. Раз решение вернулось человеку, тост и должен висеть, пока человек не ответит.
Удержание всегда одно — как и тост в TUI: новое вытесняет предыдущее. Если пока висит предупреждение по одному пермишену придёт следующий, старое предупреждение снимается и его место занимает тост ожидания.
Страховка на случай, если ответ человека не долетит (сессия оборвалась, ответили не через диалог): удержание предупреждения снимается само через notifyStalledMaxMs, по умолчанию 5 минут.
Интервал должен быть заметно меньше notifyDurationMs, иначе тост будет мигать. Побочный эффект: пока тост удерживается, он перекрывает системные тосты opencode («Copied to clipboard» и подобные).
Вне TUI (opencode serve, headless) эндпоинт просто не находит адресата — ошибка проглатывается, на решение это не влияет.
Отдельный TUI-плагин для этого не нужен и не используется: exports["./tui"] opencode на практике не подхватывает — подробности и проверенные версии в AGENTS.md.
The TUI holds exactly one toast: show puts it into currentToast, replacing the previous one, and starts a timer for duration (5000 ms by default). There is nothing to hide a toast with — the API has neither a hide call nor an identifier — and the user cannot dismiss it by hand either: the component takes neither a key nor a click.
So "stays up while the work is going on" is done by re-sending: while the toast should stay up, the plugin shows the same toast again every notifyHeartbeatMs, and every show resets the timer. That way the toast does not vanish in the middle of a wait on a slow judge, and does not stick forever if the plugin dies.
Both toasts are held this way, but the events that release the hold differ:
- the waiting toast is held while the plugin waits for the judge and released as soon as the verdict arrives — after that it fades on its own after
notifyDurationMs, or the warning replaces it right away; - the warning is held until a human answers the permission: it is released by the
permission.repliedevent carrying the same identifier. Since the decision came back to the human, the toast should stay up until the human answers.
There is always exactly one hold — just like the one toast in the TUI: a new one evicts the previous. If a fresh request arrives while a warning from an earlier permission is still up, that warning is released and the waiting toast takes its place.
A safeguard in case the human's reply never arrives (the session dropped, the answer came from outside the dialog): the warning's hold releases itself after notifyStalledMaxMs, five minutes by default.
The interval must be noticeably smaller than notifyDurationMs, otherwise the toast will blink. A side effect: while a toast is being held, it covers opencode's own system toasts ("Copied to clipboard" and the like).
Outside the TUI (opencode serve, headless) the endpoint simply finds no addressee — the error is swallowed and the decision is unaffected.
A separate TUI plugin is neither needed nor used for this: in practice opencode does not pick up exports["./tui"] — the details and the versions this was verified on are in AGENTS.md.
- Диалог пермишена в TUI может на мгновение мелькнуть — он закрывается событием
permission.repliedпосле ответа плагина. - С
approve: "always"осторожнее: одобрение запоминается до конца сессии opencode. - Всегда задавайте
toolsявно. Не давайте модели-судьеexternal_directoryбез белого списка. - Reasoning-модели OpenAI (o-серия, gpt-5) не принимают
max_tokensиtemperature: 0. Плагин определяет это по ответу 400, повторяет запрос сmax_completion_tokensи безtemperatureи запоминает режим до конца сессии. - Плагин работает на серверном пути opencode — то есть в TUI,
opencode serveиattach. В headless-режимеopencode run(без--auto) сам авто-реджектит пермишен, не дожидаясь ответа плагина: проверено на 1.18.23, в логе видноpermission requested: ...; auto-rejectingсразу после того, как плагин уже вернулallow. Для headless-сценариев есть нативныйopencode run --auto. - В событии
permission.repliedидентификатор пермишена на рантайме 1.18.23 лежит вrequestID, хотя типы@opencode-ai/sdkназывают это полеpermissionID. Плагин читает оба. - Совместимость: opencode 1.14+, где событие называется
permission.asked, а ответ идёт через/session/{id}/permissions/{permissionID}(с fallback на/permission/{requestID}/reply).
- The permission dialog in the TUI may flash for an instant — it is closed by the
permission.repliedevent after the plugin answers. - Be careful with
approve: "always": the approval is remembered until the end of the opencode session. - Always set
toolsexplicitly. Do not hand the judge modelexternal_directorywithout an allowlist. - OpenAI's reasoning models (the o-series, gpt-5) reject
max_tokensandtemperature: 0. The plugin detects this from a 400 response, retries withmax_completion_tokensand withouttemperature, and remembers that mode until the end of the session. - The plugin runs on opencode's server path — that is, in the TUI, in
opencode serveand inattach. In headlessopencode run(without--auto) the CLI auto-rejects the permission itself without waiting for the plugin: verified on 1.18.23, where the log showspermission requested: ...; auto-rejectingright after the plugin has already returnedallow. For headless scenarios there is the nativeopencode run --auto. - In the
permission.repliedevent the permission identifier lives inrequestIDon the 1.18.23 runtime, even though the@opencode-ai/sdktypes call that fieldpermissionID. The plugin reads both. - Compatibility: opencode 1.14+, where the event is called
permission.askedand the answer goes through/session/{id}/permissions/{permissionID}(with a fallback to/permission/{requestID}/reply).
npm install
npm run typecheck
npm testТесты (node --test, без внешних зависимостей) поднимают плагин с мок-клиентом и мок-fetch и фиксируют инварианты безопасности: деструктивная команда → reject, пермишен вне tools / из skipTools → молчание, недоступная LLM → молчание, ask → молчание, недоверенные метаданные — внутри <untrusted_data>.
Типы — из пакета @opencode-ai/plugin. Плагин исполняется в Bun-рантайме самого opencode, отдельная сборка не требуется.
The tests (node --test, no external dependencies) start the plugin with a mock client and a mock fetch and pin down the safety invariants: a destructive command → reject, a permission outside tools or inside skipTools → silence, an unreachable LLM → silence, ask → silence, untrusted metadata → inside <untrusted_data>.
The types come from the @opencode-ai/plugin package. The plugin runs in opencode's own Bun runtime, so no separate build step is needed.