Plugins de desktop finalmente podem apertar os próprios botões do app: ctx.runAction e ctx.pet.say

Você escreveu um plugin de desktop que adiciona um botão na titlebar que abre o painel de navegador nativo. A implementação era uma linha: disparar um evento de teclado sintético Ctrl+Shift+L em document.body. Duas semanas depois, um usuário relata que o botão não faz nada — ele redefine aquele atalho, ou o remove por completo. O seu botão continua ali, o mundo mudou, e ele não reporta erro nenhum.
Esse é um tipo bem típico de fragilidade em um ecossistema de plugins: para chamar recursos do próprio host, você tinha que fingir que o usuário apertou uma tecla. A mesma janela produziu um exemplo irmão: um plugin que mostra saldos de cota pintava balões sobre o pet do core encontrando-o com document.querySelector('canvas[aria-label$=" pet"]'), adicionando uma camada de posição fixa e seguindo o pet a cada frame. Uma mudança de markup no core e ele quebra.
Duas mudanças mescladas nesta janela transformam ambos em APIs suportadas.
ctx.runAction: a porta da frente para ações do app
const result = ctx.runAction('view.showBrowser')
// → { ok: true }
A parte importante é qual handler roda. O useKeybinds já guarda a única tabela de handlers por onde os atalhos despacham. Essa tabela agora também é registrada no runner de ações nativo (lib/keybinds/plugin-actions.ts), e ctx.runAction busca o handler lá quando é chamado — então uma chamada de plugin e o aperto de uma tecla compartilham um único caminho de código, e o key binding nunca é consultado. Redefina o que quiser; o botão continua funcionando.
Uma chamada retorna uma de duas formas e nunca lança exceção:
ctx.runAction('view.showBrowser') // → { ok: true }
ctx.runAction('session.archive') // → { ok: false, reason: 'denied', error: "'session.archive' is not available to plugins. …" }
ctx.runAction('app.quit') // → { ok: false, reason: 'unknown', error: "'app.quit' is not a Hermes app action. …" }
ctx.runAction('view.showBrowser') // → { ok: false, reason: 'unavailable' } // app shell not mounted yet
reason tem três valores: unknown (não é um id de ação válido), denied (válido, mas indisponível para plugins), unavailable (cedo demais na inicialização — o shell ainda não foi montado). Toda recusa também registra um aviso [plugin:<id>] runAction: …, para que você veja exatamente qual plugin foi barrado.
A API vive no ctx por plugin (como ctx.pet e ctx.os), e não no host global, porque uma recusa precisa ser atribuída a um plugin. Detectar um host mais antigo é uma checagem só:
if (ctx.runAction) { /* host mais novo */ }
Os types do SDK vêm junto: o @hermes/plugin-sdk exporta PluginAppActionId (uma união dos ids permitidos), PluginAppActionInfo, PluginRunActionResult e a lista de runtime PLUGIN_APP_ACTIONS. Para uma lista enumerável em runtime, pergunte ao host:
ctx.listActions()
// → [{ id: 'view.showBrowser', label: 'Toggle browser', category: 'view' }, …]
A allowlist contém apenas views e navegação
As ações permitidas são deliberadamente estreitas — “olhar para coisas e ir a lugares”:
view.showBrowser, view.showFiles, view.toggleSidebar, view.toggleRightSidebar, view.toggleReview, view.toggleStatusbar, view.findInPage, composer.focus, session.new, session.focusSearch, conversation.scrollPageUp, conversation.scrollPageDown, nav.commandPalette, nav.settings, nav.profiles, nav.capabilities, nav.messaging, nav.artifacts, nav.cron, nav.agents, keybinds.openPanel.
Recusadas: arquivar uma sessão, fixar, trocar de profile, o seletor de modelo, nível de raciocínio, voz e ditado, terminais, nova janela, abrir pasta, worktree, o HUD, fechar ou reabrir uma aba. Operações destrutivas e privilegiadas não são expostas de jeito nenhum: excluir uma sessão, sair da conta, aprovações, mudanças de modelo ou provider, atualizar, encerrar.
O raciocínio é simples: um plugin pode mover a interface para onde você quiser, mas ele não decide coisas irreversíveis por você. Um plugin de terceiros clicando errado em uma exclusão de sessão não é algo que você consiga explicar a um usuário.
ctx.pet.say: deixe o pet bubble do core falar pelo plugin
const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', tone: 'info', ttlMs: 8000 })
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' }) // same id replaces the line in place
ctx.pet.clear('balance') // remove one line; ctx.pet.clear() removes all of yours
ctx.pet.visible // ReadableAtom<boolean>: is a pet on screen?
O host desenha o balão, e o plugin nunca toca no DOM do app — que é exatamente a vantagem sobre “encontre o pet e pinte a sua própria camada”: as linhas continuam funcionando quando o pet é destacado para a sua própria janela.
O host impõe limites para que esse canto da tela não seja abusado:
- Apenas texto puro. Caracteres de controle e overrides de bidi são removidos, o texto é colapsado em uma linha e limitado a 120 caracteres, e renderiza como um text node do React.
- Rotulado. O nome do plugin, vindo do inventário de Plugins, aparece como um pequeno rótulo acima da linha.
- Curto e limitado. As linhas expiram depois de 6 segundos por padrão (limitadas a 1–30 s). Cada plugin pode ter até 3 linhas ativas e 10 chamadas de
saya cada 10 segundos; chamadas extras são descartadas com um aviso no console. - O status do core ganha. Quando o agente tem um erro ou está esperando você, o balão do core assume; a linha do plugin volta depois, se ainda não tiver expirado.
- Segue a configuração de pet do usuário. Sem pet, ou com o pet desligado, nada aparece.
- Limpeza garantida. Desabilitar, descarregar ou fazer hot-reload de um plugin remove todas as suas linhas.
- Nenhum novo modelo de permissão.
ctx.pettem escopo e atribuição por plugin, do mesmo jeito quectx.os.
Como é um plugin de botão completo
// desktop/plugin.js
export default {
id: 'browser-toggle',
titlebarButtons: [
{
label: 'Browser',
onClick(ctx) {
const r = ctx.runAction('view.showBrowser')
if (!r.ok) {
ctx.pet.say(`browser: ${r.reason}`, { id: 'btn', tone: 'warn', ttlMs: 4000 })
}
},
},
],
}
A implementação antiga era um evento de tecla sintético. Esta reporta por que falhou — e pode avisar o usuário sem um modal.
Quando você pode usar
As duas mudanças entraram recentemente, e o corpo da release v0.21.7 as lista entre as mudanças desta janela (ele não as detalha ali e adia as notas curadas para a v0.22.0). O app desktop tem a sua própria cadência de empacotamento, então:
- Instalações via código-fonte e setups de dev do desktop que acompanham a main: disponível agora.
- Desktop Store / MSIX / empacotado: espere a próxima atualização desse build. Faça o garden com
ctx.runAction?.(…)para que um único plugin funcione nos dois hosts.
A documentação também foi atualizada: website/docs/developer-guide/desktop-plugin-sdk.md ganhou uma seção Run app actions — ctx.runAction com a API, um exemplo e a tabela da allowlist, e a referência desktop-plugins.md da skill empacotada hermes-agent traz a mesma nota.
Ressalvas
- Pare de disparar eventos de tecla sintéticos para acionar recursos do app. É exatamente o padrão que está sendo aposentado: ele morre quando um usuário redefine o atalho e falha em silêncio.
- Trate as recusas.
ok: falsenão é uma exceção — ignorá-lo apenas repassa o bug aos seus usuários. - O pet bubble não é um painel de log. 120 caracteres, no máximo 3 linhas, 6 segundos por padrão: é um status de relance, não um canal de saída.
- A allowlist muda entre versões. Leia-a em runtime com
ctx.listActions()em vez de deixá-la hardcoded em docs ou type assertions.
Para construir um plugin de desktop do zero, veja o guia do SDK de plugins de desktop; para saber de onde vêm os plugins e como são revisados, veja o guia do catálogo de plugins; o modelo de segurança está em isolamento de plugins e a fronteira do host.