Skip to content

Repository files navigation

opencode-auto-permission

Плагин для 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.

Как это работает / How it works

Хук permission.ask из документации opencode на актуальных версиях (1.14–1.18) не триггерится — это мёртвый тип, из-за чего большинство похожих плагинов из интернета не работают. Этот плагин использует рабочий механизм:

  1. Плагин подписывается на хук event и ловит bus-событие permission.asked — то самое, на которое отвечает диалог в TUI.
  2. Тривиальные read-only команды (git status, ls, pwd…) одобряются локально, без обращения к модели, а то, что судья одобрил трижды, плагин запоминает сам — см. файл правил.
  3. Иначе собирается контекст запроса: инструмент, паттерны, метаданные (команда, diff, путь к файлу) и последняя реплика пользователя в сессии — чтобы судья видел, о чём его вообще просили.
  4. Контекст уходит второй модели (OpenAI-совместимый API или Anthropic) со строгим системным промптом; идентичные запросы берутся из кэша.
  5. Ответ парсится как строгий 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:

  1. The plugin subscribes to the event hook and catches the permission.asked bus event — the very one the TUI dialog answers.
  2. 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.
  3. 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.
  4. 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.
  5. The answer is parsed as strict JSON {"decision":"allow|deny|ask","reason":"..."}, and the plugin replies to the permission through the opencode API.

Три решения, а не два / Three answers, not two

Решение Что делает плагин
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.

Защита от промпт-инъекций / Prompt injection defence

Метаданные пермишена (текст команды, 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.

Зачем судье реплика пользователя / Why the judge needs the user's request

Без неё судья видит голую команду без всякой цели. 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.

Файл правил / The rules file

Плагин ведёт один файл правил на проект и решает по нему, не тратя ни времени, ни токенов. Файл лежит вне репозитория — ~/.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 bash permission 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 that find . -delete does 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 feature from git push origin main;
  • only allow is stored. A refusal is never remembered — otherwise one mistaken deny would stick forever and stay invisible;
  • bash only.

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.

Судья по умолчанию / The default judge

Модель судьи можно не настраивать вовсе. Без опции 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.

Кэш и аудит / Cache and audit log

Одинаковые запросы (тот же пермишен + паттерны + метаданные) решаются один раз за сессию — повтор берётся из кэша. Решения 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

Установка / Installation

В глобальный конфиг ~/.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.

Опции / Options

Опция По умолчанию Описание
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

Примеры / Examples

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"
}]

Тост: чтобы было видно, что судья работает / The toast: showing that the judge is working

Диалог пермишена рисует сам 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 ask or 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

Почему тост переотправляется / Why the toast is re-sent

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.replied event 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.

Замечания / Notes

  • Диалог пермишена в 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.replied event after the plugin answers.
  • Be careful with approve: "always": the approval is remembered until the end of the opencode session.
  • Always set tools explicitly. Do not hand the judge model external_directory without an allowlist.
  • OpenAI's reasoning models (the o-series, gpt-5) reject max_tokens and temperature: 0. The plugin detects this from a 400 response, retries with max_completion_tokens and without temperature, 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 serve and in attach. In headless opencode run (without --auto) the CLI auto-rejects the permission itself without waiting for the plugin: verified on 1.18.23, where the log shows permission requested: ...; auto-rejecting right after the plugin has already returned allow. For headless scenarios there is the native opencode run --auto.
  • In the permission.replied event the permission identifier lives in requestID on the 1.18.23 runtime, even though the @opencode-ai/sdk types call that field permissionID. The plugin reads both.
  • Compatibility: opencode 1.14+, where the event is called permission.asked and the answer goes through /session/{id}/permissions/{permissionID} (with a fallback to /permission/{requestID}/reply).

Разработка / Development

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.

Лицензия / License

MIT

About

Auto-mode permissions for opencode: a second LLM decides allow/deny instead of the human

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages