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 say a 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.pet tem escopo e atribuição por plugin, do mesmo jeito que ctx.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: false nã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.