Documentação oficial desta página · v0.20.6
SDK de plugin do desktop
O aplicativo nativo Hermes Desktop é movido a contribuições: toda superfície da janela — painéis, rotas, navegação lateral, itens da barra de status, entradas da paleta, atalhos, temas — se registra num registro central. O núcleo registra as superfícies dele exatamente do mesmo jeito que um plugin faz, então a história de plugin é a de verdade, não um puxadinho.
Um plugin de desktop é um único arquivo ESM que exporta por padrão um HermesPlugin.
Ele importa um módulo — @hermes/plugin-sdk — e ganha tudo: o estado
vivo do aplicativo, a porta JSON-RPC do gateway, um namespace REST/socket de backend com escopo,
o React Query e o próprio kit de interface do aplicativo, para a interface do plugin ficar nativa por padrão. Sem
clonar repositório, sem npm run build, sem remendar o código-fonte do aplicativo. Solte o arquivo em
$HERMES_HOME/desktop-plugins/<id>/plugin.js e o aplicativo o carrega em poucos segundos
e recarrega a quente a cada gravação.
"Plugin" quer dizer várias coisas sem relação entre si no Hermes. Esta página é o SDK do
aplicativo nativo de desktop (hermes desktop) — o módulo @hermes/plugin-sdk e o
$HERMES_HOME/desktop-plugins/. O painel web (hermes dashboard) tem
o próprio sistema de plugins, sem relação com este, em window.__HERMES_PLUGIN_SDK__, com um
manifest.json — documentado em
Estender o painel. Os plugins Python
de CLI/gateway estão documentados em Criar um plugin do Hermes.
Os três não compartilham código, APIs nem forma de entrega. Só o namespace do backend plugin_api.py
(/api/plugins/<id>) é compartilhado entre os SDKs de desktop e de painel.
Modelo mental
O SDK segue o modelo de módulo do VS Code. Quem escreve um plugin importa exatamente um módulo e nunca toca nas partes internas do aplicativo (elas ficam cercadas por lint num plugin empacotado e não resolvem num plugin de disco). A capacidade vem em camadas:
host.state.*— visões somente leitura sobre o estado vivo do aplicativo (átomos de nanostore): sessão ativa, ocupação de turno por sessão, cwd, status do socket do gateway, modelo, perfil, viewport. Ogatewayé o WebSocket, não a ocupação de turno.- Ações de
host.*— verbos seguros e curados: toast, navegar, acompanhar logs, reiniciar o gateway, assinar o fluxo de eventos do gateway. host.request— a porta JSON-RPC do gateway: sessões, configuração, skills, cron — tudo que o próprio aplicativo chama.ctx.rest/ctx.socket— o namespace de backend do seu próprio plugin (/api/plugins/<id>), se você entregar umplugin_api.py.ui.*— a linguagem de design: os componentes reais do aplicativo, as variáveis de tema, os ícones e os formatadores, para a sua interface bater pixel a pixel com o aplicativo.
Dois modos de entrega
| Modo | Onde | Quem | Passo de build |
|---|---|---|---|
| Disco (recomendado) | $HERMES_HOME/desktop-plugins/<id>/plugin.js | usuários, agentes | nenhum — ESM puro, carregado sem compilar |
| Pacote unificado | $HERMES_HOME/plugins/<id>/desktop/plugin.js | plugins que também entregam código do lado do agente | nenhum — mesma esteira de disco |
| Empacotado | apps/desktop/src/plugins/<id>/plugin.tsx | dentro da árvore, entregue com o aplicativo | o build Vite do próprio aplicativo |
Os três seguem o mesmo contrato HermesPlugin, aparecem em Settings → Plugins
e ligam/desligam ao vivo. Um pacote unificado é só a porta de disco varrendo dentro
da pasta do seu plugin de agente — veja
Um pacote, os dois SDKs. Tudo nesta página está
escrito para a porta de disco (o que você e o agente escrevem);
Plugins empacotados aponta as duas
diferenças. Hoje não há plugin de desktop entregue na árvore do núcleo — as demonstrações de
referência ficam no repositório companheiro
hermes-example-plugins.
Início rápido — o seu primeiro plugin
Crie $HERMES_HOME/desktop-plugins/hello/plugin.js (que é ~/.hermes/...
por padrão, ou ~/.hermes/profiles/<name>/... com um perfil nomeado). O nome da pasta
precisa ser igual ao id do plugin.
// ~/.hermes/desktop-plugins/hello/plugin.js
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
function HelloPane() {
const gateway = useValue(host.state.gateway)
return jsxs('div', {
className: 'flex h-full flex-col gap-2 p-3 text-sm',
children: [
jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
jsx('div', {
className: 'text-(--ui-text-tertiary)',
children: `gateway: ${gateway}`
})
]
})
}
export default {
id: 'hello', // precisa bater com o nome da pasta
name: 'Hello',
register(ctx) {
ctx.register({
id: 'pane',
area: 'panes',
title: 'hello',
data: { placement: 'right', width: '260px' },
render: () => jsx(HelloPane, {})
})
ctx.register({
id: 'chip',
area: 'statusBar.right',
order: 130,
render: () =>
jsx('button', {
type: 'button',
className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
onClick: () => {
haptic('tap')
host.notify({ kind: 'info', message: 'Hello from my plugin!' })
},
children: 'hello'
})
})
}
}
Grave o arquivo. O aplicativo observa a pasta desktop-plugins/, carrega o arquivo em poucos segundos
e recarrega a quente, no lugar, a cada gravação seguinte. Se ele não aparecer, rode ⌘K →
Reload desktop plugins. Se o carregamento falhar, um toast nomeia o erro — corrija e
grave de novo.
O arquivo de disco é carregado sem compilar, então a sintaxe JSX não vai ser interpretada. Escreva a interface
com chamadas de jsx() / jsxs() do react/jsx-runtime (ou com React.createElement).
Os únicos especificadores importáveis são @hermes/plugin-sdk, react e
react/jsx-runtime — todo o resto falha na resolução, de propósito.
O contrato do plugin
Um plugin exporta por padrão um HermesPlugin:
interface HermesPlugin {
/** Slug estável — vira a origem `plugin:<id>` e o namespace de ids. */
id: string
/** Nome legível para a interface de Settings / sobre. Por padrão, o `id`. */
name?: string
/** Registra no carregamento quando o usuário ainda não escolheu (padrão true). Coloque false
* em plugins opt-in: eles aparecem no inventário em Settings ▸ Plugins, desligados até o
* usuário virar a chave. */
defaultEnabled?: boolean
/** Chamado uma vez no carregamento; fie as contribuições pelo `ctx`. */
register: (ctx: PluginContext) => void
}
O register recebe um PluginContext com escopo. Ele nunca toca no registro
direto — o contexto marca a procedência sozinho (source: 'plugin:<id>') e
dá namespace a todo id de contribuição (<id>:<localId>), então dois plugins nunca
colidem.
interface PluginContext {
/** Etiqueta de origem resolvida, por exemplo `'plugin:hello'`. */
readonly source: string
/** Registra uma contribuição (id com namespace, origem carimbada). Devolve um descartador. */
register: (c: PluginContribution) => () => void
/** Registra várias de uma vez; o descartador devolvido remove todas elas. */
registerMany: (cs: PluginContribution[]) => () => void
/** REST para o namespace de backend deste plugin (`/api/plugins/<id>`). */
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
/** WebSocket ao vivo para o namespace deste plugin. Devolve um descartador. */
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** A porta curada do SO: notificação nativa, abrir-externo, revelar-no-gerenciador-de-arquivos, área de transferência. */
os: PluginOs
/** Persistência JSON com escopo de plugin (as chaves ficam sob `hermes.plugin.<id>.`). */
storage: PluginStorage
}
Uma contribuição é a primitiva que toda superfície compartilha:
interface Contribution {
id: string // você escreve o id local; o host dá o namespace
area: string // ONDE ela vai (uma constante de área de contribuição)
title?: string
order?: number // ordenação dentro da área (menor = antes)
when?: () => boolean // visibilidade dinâmica; reavaliada pela área
enabled?: boolean
render?: () => ReactNode // o componente a montar
data?: unknown // carga útil específica da área (veja o livro de receitas)
}
Você fornece render, data, ou os dois, conforme a área.
Áreas de contribuição — o livro de receitas
Importe as constantes de área do SDK; cada área tem a própria carga útil de data.
| Superfície | area | O que você fornece |
|---|---|---|
| Painel de layout | PANES_AREA ('panes') | title + render + data: { placement, dock?, width?, height? } |
| Página inteira | ROUTES_AREA | data: { path } + render |
| Navegação lateral | SIDEBAR_NAV_AREA | data: { path, label, codicon } |
| Barra de status | STATUSBAR_AREAS.left / .right | render (ou data como StatusbarItem) |
| Barra de título | TITLEBAR_AREAS.left / .center / .right | data como TitlebarTool, ou um <Contribute> com escopo de montagem |
| Paleta ⌘K | PALETTE_AREA | data: PaletteContribution |
| Atalho | KEYBINDS_AREA | data: KeybindContribution |
| Tema | THEMES_AREA | data como um DesktopTheme |
| Compositor | COMPOSER_AREAS.* | slots de render, ou provedores de middleware / de anexo |
Painéis
Um painel é um ladrilho na árvore de layout. O placement é o papel semântico — o painel
se empilha (como abas) com os painéis existentes daquele papel; o usuário pode arrastá-lo para
qualquer lugar depois.
ctx.register({
id: 'pane',
area: 'panes',
title: 'my pane',
data: { placement: 'right', width: '260px' },
render: () => jsx(MyPane, {})
})
O placement é 'main' | 'left' | 'right' | 'top' | 'bottom'. Para cair numa borda
específica em vez de empilhar, acrescente um gesto de dock — a mesma coisa que
arrastar até a marca de soltura de um painel:
// Abaixo da conversa, com 200px de altura.
data: {
placement: 'bottom',
dock: { pane: 'workspace', pos: 'bottom' },
height: '200px'
}
O dock.pane é qualquer id de painel (workspace é a thread principal; também sessions,
terminal, files, review, logs); o dock.pos é
'top' | 'bottom' | 'left' | 'right' | 'center'. Declare uma width/height para
o painel não tomar metade da zona.
Fechar o único painel contribuído por um plugin desliga aquele plugin, que pode ser religado em Settings → Plugins. Quando um plugin contribui com vários painéis, fechar um dispensa só aquele painel e deixa os outros painéis, comandos e middlewares do plugin ativos. O Reset layout restaura os painéis contribuídos que foram dispensados.
Páginas e navegação lateral
Uma rota monta uma página inteira no painel de espaço de trabalho, como qualquer visão embutida. Combine-a com uma linha de navegação lateral (e/ou um comando de paleta) para deixá-la alcançável.
import { ROUTES_AREA, SIDEBAR_NAV_AREA } from '@hermes/plugin-sdk'
ctx.registerMany([
{
id: 'page',
area: ROUTES_AREA,
data: { path: '/my-page' },
render: () => jsx(MyPage, {})
},
{
id: 'nav',
area: SIDEBAR_NAV_AREA,
data: { path: '/my-page', label: 'My Page', codicon: 'project' }
}
])
O codicon é um id de codicon do VS Code.
Navegue até uma rota de qualquer lugar com host.navigate('/my-page').
Barra de status e barra de título
Os itens da barra de status são renderizados no agrupamento esquerdo ou direito da barra de baixo.
O mais simples é uma função render; para um botão comum, use data como um
StatusbarItem ({ id, label?, icon?, detail?, variant?, menuItems?, … }).
import { STATUSBAR_AREAS, TITLEBAR_AREAS } from '@hermes/plugin-sdk'
ctx.register({
id: 'count',
area: STATUSBAR_AREAS.right,
order: 120,
render: () => jsx(MyStatus, {})
})
As ferramentas de barra de título ficam em TITLEBAR_AREAS.left | .center | .right como dados de
TitlebarTool ({ id, label, icon, active?, onSelect? }).
Comandos de paleta e atalhos
import { PALETTE_AREA, KEYBINDS_AREA } from '@hermes/plugin-sdk'
ctx.registerMany([
{
id: 'open',
area: PALETTE_AREA,
data: {
id: 'my-page.open',
label: 'Open My Page',
keywords: ['my', 'page'],
run: () => host.navigate('/my-page')
}
},
{
id: 'refresh',
area: KEYBINDS_AREA,
data: {
id: 'my-page.refresh',
label: 'Refresh My Page',
category: 'My Plugin',
defaults: ['mod+shift+r'],
run: () => void doRefresh()
}
}
])
Os atalhos podem ser remapeados pelo usuário nas configurações; o defaults é só a ligação inicial.
Temas
Uma contribuição de tema entrega um DesktopTheme completo como data (nome, rótulo,
cores, …). Ele aparece no seletor de temas como se fosse embutido.
import { THEMES_AREA } from '@hermes/plugin-sdk'
ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
Registrar um tema o lista; não o seleciona. O useTheme() lê a
aparência pintada (theme, themeName, availableThemes, resolvedMode) e a
muda (setTheme, setMode, previewTheme) a partir de um componente:
import { Button, useTheme } from '@hermes/plugin-sdk'
function ThemePicker() {
const { availableThemes, setTheme, themeName } = useTheme()
return availableThemes.map(t => (
<Button key={t.name} disabled={t.name === themeName} onClick={() => setTheme(t.name)}>
{t.label}
</Button>
))
}
Uma troca disparada por algo que não seja um render — um gateway conectando, um
evento de socket, qualquer callback de host.onEvent — não tem componente onde pendurar o
hook. Use requestTheme(name) nesses casos. Um nome que não resolve é recusado em vez de
convertido para a pele padrão, então o valor de retorno serve também como checagem de
disponibilidade, e um nome errado nunca pode redefinir a aparência de alguém em silêncio:
import { host, requestTheme } from '@hermes/plugin-sdk'
host.onEvent('gateway.ready', () => {
if (!requestTheme('noir')) {
host.notifyError('Connected, but the noir theme is not installed.')
}
})
As duas portas persistem por perfil, então uma troca feita por plugin gruda igual a uma
escolha manual. Para tingir o tema ativo em vez de trocá-lo, use
setAccentOverride(hex) e limpe no ctx.onDispose — o plugin accent empacotado
é o exemplo trabalhado.
Extensões do compositor
As COMPOSER_AREAS (top, bottom, leading, actions, attachments,
middleware) deixam um plugin acrescentar controles em volta do compositor de mensagem, fornecer
uma fonte de anexo, ou transformar um rascunho antes do envio (ComposerMiddleware
com um handler(draft) => draft | null).
Diretivas de transcrição — componentes inline que o modelo endereça
A TRANSCRIPT_DIRECTIVE_AREA transforma a própria transcrição numa área de contribuição.
Registre uma diretiva nomeada e o agente consegue renderizar o seu componente inline numa
mensagem de assistente, emitindo um parágrafo na forma ::name{key="value"}:
import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'
ctx.register({
id: 'task-card',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'task', // o modelo escreve ::task{id="BB-12"}
render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
}
})
Regras que o host impõe para a superfície continuar segura:
- A diretiva precisa ser o parágrafo inteiro — um
::nameno meio da prosa continua prosa, então componentes de plugin nunca conseguem sequestrar o texto corrente. - Os atributos são saída não confiável do modelo (pares
key="value", só strings). Valide os seus próprios campos; não renderize nada diante de lixo, em vez de adivinhar. - Uma diretiva não reivindicada (nenhum plugin registrado para o nome) é renderizada como o parágrafo comum que ela sempre foi — nada quebra quando um plugin está desligado.
- Os renders são envolvidos pela fronteira de erro de contribuição: uma exceção degrada para uma etiqueta de erro inline, nunca para uma mensagem morta.
- Na colisão de nome, o primeiro registro vence; dê namespace aos nomes ousados
com o seu slug (
myplugin-board, nãoboard).
O núcleo entrega uma diretiva como consumidor de referência: a ::preview{file="…"}
renderiza o arquivo HTML do espaço de trabalho ao vivo dentro da mensagem — um iframe
srcdoc em sandbox, com origem opaca (os scripts rodam e o widget é totalmente
interativo; sem alcance ao aplicativo, ao armazenamento dele ou à ponte). O quadro
se dimensiona pelo conteúdo (altura ao vivo, largura adotada do espaço intrínseco do conteúdo,
encostado à esquerda no fluxo da mensagem), e um prelúdio de tema entrega
ao documento os tokens resolvidos do aplicativo (--foreground, --muted-foreground,
--accent, --border, --card), a fonte do aplicativo e um fundo
transparente — então um HTML em forma de widget se lê como nativo, enquanto uma página inteira mantém
o próprio design. Alvos que não são HTML e gateways remotos recaem no
cartão de preview clássico. Conte ao agente sobre a sua diretiva numa skill (é
assim que ele aprende a emiti-la).
Os widgets em preview também conseguem responder. Dentro do quadro,
window.hermes.send('get-price eth') (ou um
<button data-hermes-send="get-price eth"> declarativo — sem precisar de script) entrega esse
prompt ao agente como um turno de usuário, fora da tela: nenhum balão ocupa a
transcrição, e a resposta visível é o widget se atualizando. O turno continua
real — ele acorda o agente, obedece às regras de condução/fila do compositor e
é gravado (com o tipo hidden), então a retomada e o banco de dados de sessão guardam o registro completo.
Os prompts são aparados, limitados a 500 caracteres e afunilados a um por segundo
por quadro.
Cromo com escopo de montagem (Contribute)
O ctx.register é para contribuições permanentes. Quando o cromo deve viver e
morrer junto com um componente que já está na tela (o controle de barra de título da própria página
some quando a página é desmontada), renderize um <Contribute> dentro dele:
import { Contribute, TITLEBAR_AREAS } from '@hermes/plugin-sdk'
jsx(Contribute, {
area: TITLEBAR_AREAS.center,
id: 'my-page:switcher', // dê namespace com o seu slug
children: jsx(MySwitcher, {})
})
Ele se registra na montagem e se descarta na desmontagem, automaticamente.
API do host
Tudo que está em host é alcançável de qualquer ponto de um plugin. Os átomos de estado são
somente leitura — leia com .get() nos handlers, assine com useValue(atom) nos
componentes.
host.state.activeSessionId // ReadableAtom<string | null>
host.state.awaitingResponse // ReadableAtom<boolean> true até a primeira carga do assistente
host.state.busy // ReadableAtom<boolean> o chat em foco está trabalhando depois de um envio
host.state.busyBySession // ReadableAtom<Record<string, boolean>> id de runtime → no meio do turno
host.state.focusedSessionId // ReadableAtom<string | null> (id de runtime da sessão EM FOCO — ciente de ladrilhos; prefira para RPC de session.*)
host.state.focusedSessionProfile // ReadableAtom<string> (perfil dono do chat em foco — prefira este a `profile` para leituras por bot/perfil)
host.state.focusedStoredSessionId // ReadableAtom<string | null> (id durável — navegação / casamento com a lista de sessões)
host.state.focusedUsage // ReadableAtom<UsageStats | null> (uso transmitido ao vivo da sessão em foco, sem precisar de RPC)
host.state.cwd // ReadableAtom<string>
host.state.gateway // ReadableAtom<string> estado do socket ('idle' | 'connecting' | 'open' | …)
host.state.model // ReadableAtom<string>
host.state.profile // ReadableAtom<string>
host.state.viewport // ReadableAtom<{ width, height, narrow }>
O host.state.gateway é a conexão WebSocket, não se um turno de chat está
rodando. Uma sessão pode estar no meio de um turno enquanto o socket está open; outra sessão
pode estar ociosa ao mesmo tempo. Desabilite o compositor ou as ações do plugin a partir da
ocupação de turno da sessão em foco (host.state.busyBySession[sessionId], ou o
view.$busy daquela sessão) — nunca a partir de gateway, e nunca de uma flag global de
ocupação por processo.
host.notify({ kind, message, title?, detail?, action? }) // toast; devolve o id
host.notifyError(error, fallbackMessage) // mostra um erro em toast
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
// notificação nativa do SO (atribuída ao seu plugin)
ctx.os.openExternal(url) // handler padrão do SO (navegador, e-mail, spotify:) → Promise<boolean>
ctx.os.revealPath(path) // revela no Finder / Explorer → Promise<boolean>
ctx.os.writeClipboard(text) // área de transferência do sistema → Promise<boolean>
host.navigate('/route') // navegação por rota em hash
host.openSession(id, { profile?, intent? }) // abre uma sessão gravada no estilo do núcleo;
// profile: troca suave para o backend daquele perfil antes
// intent: 'in-place' (padrão) | 'stack' | 'tab' | 'window'
host.newChat(profile?) // rascunho de chat novo, opcionalmente em outro perfil
host.openWorkspace(id, { render, title?, minWidth?, onClose? })
// encaixa uma aba renderizada pelo plugin na zona
// PRINCIPAL de espaço de trabalho e a revela; devolve um descartador
host.paneVisibility(paneId) // ReadableAtom<boolean> — um painel contribuído está
// mesmo na tela (é a aba ativa da zona dele)?
host.onEvent(type, fn) // fluxo de eventos do gateway ('*' = todos); devolve um descartador
host.logs(...) // acompanha um arquivo de log do aplicativo
host.status() // instantâneo único do status do sistema
host.restartGateway() // reinicia o gateway do backend
host.profileRoutes() // [{ profile, targetProfile, connectionId, mode }]
host.requestProfile<T>(route, method, params?) // RPC roteado pelo registro; sem troca em primeiro plano
host.requestProfile<T>(profile, method, params?) // sobrecarga antiga v1/local
host.request<T>(method, params?) // JSON-RPC no gateway ativo — o poder de verdade
O host.request é o mesmo JSON-RPC que o próprio aplicativo usa (sessões, configuração, skills,
cron, kanban, …). O host.requestProfile aceita um descritor de
host.profileRoutes() e roteia aquele RPC pela origem exata de registro e pelo
perfil exato, sem mudar o chat ativo nem o gateway. A sobrecarga só de perfil é
mantida só para a topologia local única/antiga; plugins cientes do registro devem passar
o descritor, para que duas origens que expõem o mesmo nome de perfil não colidam.
O host.openWorkspace(id, { render, title?, minWidth?, onClose? }) encaixa uma
visão renderizada pelo plugin na zona principal de espaço de trabalho — a mesma área central que os
ladrilhos de sessão e os previews usam — como uma aba, e a revela. Chamá-lo de novo com
o mesmo id atualiza o conteúdo no lugar e traz a aba para a frente, em vez de
abrir uma duplicata. Fechar a aba (pelo controle de fechar da aba ou por ⌘W) desmonta
o registro e dispara o seu onClose; o descartador devolvido fecha a aba de forma
programática. Detecte o recurso (typeof host.openWorkspace === 'function') e recaia num painel contribuído comum em builds de desktop mais antigas —
as salas de conversa em grupo do Bot Mode são o consumidor de referência (tomada da janela
principal quando disponível, visão dentro do painel em caso contrário).
O host.paneVisibility(paneId) devolve um átomo reativo somente leitura que é true
enquanto um painel contribuído está mesmo na tela: presente na árvore de layout,
não dispensado nem escondido, com a zona dele não minimizada, e ocupando a vaga de aba
ativa da zona (um painel sozinho na própria zona conta). O id é o
id de painel com escopo de contribuição, <pluginId>:<paneId>. Os átomos são memoizados por id,
então chamá-lo no render é seguro. Use-o para registrar interface acompanhante só enquanto
o seu painel estiver visível — o painel de Cronjobs do Bot Mode é o consumidor de referência: ele
se registra enquanto o painel de Bots ocupa a aba da barra lateral e se desregistra quando o
usuário volta para Sessions. Detecte o recurso em desktops mais antigos
(typeof host.paneVisibility === 'function') e recaia no
comportamento de registro permanente.
O host.profileRoutes() inventaria toda origem registrada no registro de conexões
atual. Origens SSH que conectam sob demanda expõem uma rota-semente default sem
credencial, sem abrir túnel, então um plugin pode ser o primeiro a discá-las;
um remoteProfile de SSH continua sendo o targetProfile de backend da rota. O connectionId
é a identidade de roteamento no registro;
combine-o com profile para chaves e persistência. Endpoint, token, host/chave de SSH e
outros campos brutos de conexão nunca cruzam a fronteira de IPC do plugin. O profile é a rota
local da origem usada
nas requisições; o targetProfile é o perfil de backend do Hermes servido por aquela rota.
Eles diferem quando uma rota mapeia explicitamente para outro perfil de backend (por exemplo, uma
sobrescrita de remoteProfile no SSH, ou um apelido antigo de URL por perfil). Essa distinção
preserva a identidade do backend sem expor segredos de conexão.
Plugins em formato de perfil também ganham métodos de primeira classe:
profiles.list (cada perfil + a conversa mais recente dele, como
last_session; passe include_sessions: false para pular a sondagem do banco por
perfil; passe preferred_session_ids: { profileName: sessionId } para uma
busca exata, com checagem de existência, de uma sessão fixada por perfil — cada
linha nomeada ganha um resumo preferred_session que resolve linhas escondidas
e linhagens de compressão até a ponta viva, ou null quando o id sumiu
de vez; gateways mais antigos ignoram o parâmetro e omitem o campo)
e profiles.create (name, description, clone_from,
clone_all, no_skills, soul, com model + provider opcionais fixados) — os
gêmeos por ws das rotas REST /api/profiles do painel.
O host.state.busy é o turno vivo do chat em foco (pensando e transmitindo).
O host.state.awaitingResponse fica true do envio até a primeira carga do
assistente. Os dois seguem o chat que o usuário está de fato olhando — o ladrilho de sessão
em foco, quando um deles está com o foco, senão o chat principal do espaço de trabalho (o mesmo
sinal que o pulso de ocupado da barra de status lê). Assine num componente:
const busy = useValue(host.state.busy)
Para detalhe no nível de token, escute com host.onEvent (message.start,
message.delta, message.complete).
O host.onEvent transmite eventos vivos do gateway (deltas de mensagem,
ciclo de vida de sessão, atividade de ferramenta). Os ouvintes são isolados — uma exceção no seu
ouvinte não afeta o despacho do aplicativo. Toda porta de host é segura para assíncrono: uma exceção síncrona
de um auxiliar interno (por exemplo, sem ponte de desktop num navegador comum) vira uma
rejeição que o seu .catch() vê, nunca uma quebra na fronteira de erro.
O ctx.os é a porta curada do SO — toda forma de um plugin alcançar fora da janela
do aplicativo, num namespace só, atribuído ao seu plugin. O ctx.os.notify posta uma
notificação nativa do SO — a mesma esteira do Electron que os alertas de
aprovação/turno do próprio aplicativo usam. Ela só dispara quando o usuário está longe do Hermes
(em segundo plano / sem foco); use o host.notify para o toast dentro do aplicativo quando
ele estiver olhando para o aplicativo. Os usuários podem silenciá-la por dispositivo em Settings ▸
Notifications ▸ "Plugin notifications", e as repetições do mesmo plugin são
afuniladas, então trate isso como sinal para eventos realmente notáveis — não como log.
Apresentação rica + ativação (estende a porta original de ctx.os):
ctx.os.notify({
title: 'New match found',
body: 'Someone matched your signal',
icon: '/abs/path/to/icon.png', // ícone da Notification do Electron
// Clique no corpo → foca o Hermes + navega. Mesmo vocabulário dos deep links do SO:
activate: 'hermes://index-network/intent/1',
// ou: activate: '/index-network/intent/1'
// ou: activate: { path: '/index-network/intent/1' }
onActivate: () => focusLocalState('1'), // callback opcional no renderer
actions: [
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
{ id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
],
})
O activate é compatível com deeplink: hermes://index-network/intent/1 e o
caminho em hash /index-network/intent/1 resolvem para a mesma rota dentro do aplicativo (e a
mesma URL hermes://… funciona como deep link do SO). Os botões de ação só aparecem em
builds assinadas do macOS; nos outros lugares, o clique no corpo continua ativando. A navegação só
acontece no clique do usuário — nunca a partir de um evento de segundo plano sozinho.
As outras portas (openExternal, revealPath, writeClipboard) resolvem
false em vez de lançar exceção quando a capacidade não está disponível (shell de desktop mais
antigo, navegador comum) — ramifique pelo resultado, em vez de farejar a ponte.
Camada de dados — React Query + nanostores
Os plugins compartilham o único QueryClient do aplicativo, então as consultas de plugin cacheiam, deduplicam,
fazem polling e invalidam exatamente como as telas do núcleo — nunca escreva um laço de fetch na mão.
import { useQuery, useMutation, useQueryClient, atom, computed, useValue } from '@hermes/plugin-sdk'
function MyPanel() {
const { data, isLoading } = useQuery({
queryKey: ['my-plugin', 'items'],
queryFn: () => host.request('my.list', {})
})
// …
}
Para estado compartilhado entre um gatilho e o painel dele (ou um laço de polling), use atom /
computed — a mesma primitiva que o host.state usa. Assine na folha que
renderiza o valor, com useValue. Para invalidar uma consulta de fora do React
(por exemplo, quando chega um quadro de ctx.socket), importe o queryClient compartilhado:
import { queryClient } from '@hermes/plugin-sdk'
ctx.socket('/events', () => {
queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] })
})
O kit de interface e a tematização
Importe os componentes reais do aplicativo direto, para a sua interface ficar nativa por padrão:
Button,Input,Textarea,Select*,Switch,Checkbox,SegmentedControl,Tabs*,Dialog*,ConfirmDialog,DropdownMenu*,ContextMenu*,Popover*,Tip/Tooltip*,Badge,Kbd/KbdGroup,SearchField,ScrollArea,Separator,Skeleton,GlyphSpinner,Loader,EmptyState,ErrorState,CopyButton,StatusDot,LogView,Codicon,DecodeText.
Mais os auxiliares: cn (junção de classes), icons.* (o conjunto lucide do aplicativo), haptic,
profileColor / profileColorSoft (cores determinísticas de identidade), os formatadores de tempo
relativeTime / fmtDateTime / fmtDayTime / coarseElapsed,
useI18n (texto localizado — o seu plugin continua traduzível) e
evaluateRuntimeReadiness.
Estilize com variáveis de tema, nunca com cores fixas no código. Os painéis já ficam sobre o
fundo de editor do aplicativo — não mexa no fundo e use variáveis para todo o
resto: var(--ui-text-secondary), var(--ui-text-tertiary),
var(--ui-text-quaternary), var(--ui-stroke-secondary), var(--ui-accent).
Para desenho em canvas, resolva-as uma vez com
getComputedStyle(canvas).getPropertyValue('--ui-accent'). É isso que faz um
plugin se re-vestir sozinho a cada tema.
Um backend para o seu plugin
Se o seu plugin precisa de trabalho do lado do servidor, entregue um plugin_api.py em Python e alcance-o
por ctx.rest / ctx.socket — um namespace com escopo no seu plugin por
construção.
Um pacote, os dois SDKs
Um recurso que precisa de interface de desktop e de código do lado do agente (um plugin Python, as
rotas de backend dele, skills) não precisa ser entregue como duas instalações codependentes. O
aplicativo de desktop também varre o $HERMES_HOME/plugins/<id>/ — a raiz normal de plugins de agente —
atrás de um desktop/plugin.js, e o carrega pela mesmíssima esteira
da porta de disco autônoma (com recarga a quente incluída):
~/.hermes/plugins/<id>/ # UMA pasta instalável
├── plugin.yaml # a metade do agente: ferramentas, hooks, comandos
├── skills/…
├── dashboard/
│ ├── manifest.json # { "name": "<id>", "api": "plugin_api.py" }
│ └── plugin_api.py # rotas de backend → /api/plugins/<id>/
└── desktop/
└── plugin.js # a metade do desktop: painéis, comandos, ctx.rest
A metade desktop/plugin.js é um plugin de disco comum — mesmo contrato, mesmos
imports, mesmo ctx.rest('/…') alcançando o plugin_api.py que está ao lado.
Instalar, compartilhar ou remover o recurso é uma pasta só.
Duas chaves de habilitação continuam valendo, de propósito, e as duas começam desligadas: a
metade do desktop é opt-in — ela aparece no inventário em Settings → Plugins, mas fica
desligada até o usuário virar a chave — igual ao portão plugins.enabled da metade Python,
no config.yaml (a fronteira de segurança abaixo). Soltar
um pacote em ~/.hermes/plugins é inerte em toda superfície até o usuário
dizer o contrário. A metade do desktop degrada com elegância quando a metade do backend está
desligada — o ctx.rest devolve erros, não quebras.
A varredura é local à máquina em que o aplicativo de desktop roda. Contra um backend
remoto, o ~/.hermes/plugins da máquina remota não é alcançável como sistema de arquivos —
só pacotes instalados localmente contribuem com uma metade de desktop (mesma regra da
porta autônoma).
Distribuir com um link de instalação
Entregue o repositório do seu plugin (metade do agente, metade do desktop, ou as duas) e aponte para ele com
o esquema hermes:// — uma âncora comum no seu site ou README:
<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>
O usuário recebe um diálogo de confirmação (id do repositório, links de origem, uma sondagem do que
o repositório entrega) e escolhe os componentes antes de qualquer instalação — deep links
nunca instalam sozinhos. O force=1 substitui uma instalação existente; builds de desenvolvimento usam
hermes-dev://. Referência completa do link:
Links de instalação em um clique.
O lado Python
Os plugins de desktop reaproveitam a montagem de backend dos plugins de painel. Ponha o backend numa
subpasta dashboard/ de um plugin comum do Hermes e declare-o num
manifest.json:
~/.hermes/plugins/<id>/
└── dashboard/
├── manifest.json # { "name": "<id>", "api": "plugin_api.py" }
└── plugin_api.py # exporta `router = APIRouter()`
# plugin_api.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/board")
async def board():
return {"items": ["one", "two", "three"]}
@router.post("/action")
async def action(body: dict):
return {"ok": True, "received": body}
As rotas são montadas sob /api/plugins/<id>/ (GET /api/plugins/<id>/board, …).
O código de backend roda dentro do processo do gateway, então pode importar direto da
base de código do hermes-agent (hermes_state, hermes_cli.config, …). Veja
Estender o painel → Rotas de API do backend
para a referência completa do backend — a montagem é idêntica.
Habilitar um plugin no painel Settings → Plugins do desktop é uma escolha do lado do
renderer; isso não importa Python. O plugin_api.py de um plugin de usuário é
importado só quando o plugin está na lista de permissão plugins.enabled do
config.yaml (e não está em plugins.disabled). Plugins de projeto (./.hermes/)
nunca importam Python automaticamente. Isso é uma fronteira de segurança, não um descuido
(GHSA-mcfc-hp25-cjv7).
Chamar o backend a partir do plugin
register(ctx) {
// REST — caminho relativo ao namespace.
const load = () => ctx.rest('/board') // GET /api/plugins/<id>/board
const act = () => ctx.rest('/action', { method: 'POST', body: { go: true } })
// Gêmeo ao vivo — um WebSocket para o seu próprio namespace.
const stop = ctx.socket('/events', frame => {
queryClient.invalidateQueries({ queryKey: [ctx.source, 'board'] })
})
}
O ctx.rest é ciente de perfil e recusa travessia de caminho (..), então você nunca consegue
endereçar a API de outro plugin nem uma rota do núcleo por ele. O PluginRestOptions é
{ method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }.
O ctx.socket se reconecta sozinho com recuo até ser descartado. Ele vira um no-op
em remotos por OAuth (os tíquetes de WS de uso único são administrados pelo núcleo) — trate o socket como
um acelerador em cima do polling, nunca como substituto. Todo consumidor precisa de um plano B por
polling de qualquer jeito, já que qualquer socket pode cair.
Para dados de todo o gateway (e não do seu namespace), use o host.request (JSON-RPC) e o
host.onEvent (o fluxo de eventos do gateway).
Configurações, estado de habilitação e armazenamento
Todo plugin — habilitado ou não — aparece no inventário em Settings → Plugins, onde o usuário o liga e desliga ao vivo (sem reiniciar o aplicativo), revela a pasta dele ou faz nova varredura. A escolha do usuário é lembrada:
- Sem escolha ainda → vale o
defaultEnableddo próprio plugin (padrãotrue). ColoquedefaultEnabled: falsepara entregar um plugin opt-in, que fica apagado até o usuário ligá-lo. - Escolha explícita → gravada e respeitada entre reinícios. Um plugin desligado fica desligado — não brigue com isso; o usuário desligou você.
Guarde o seu próprio estado com o ctx.storage, com namespace no seu plugin
(hermes.plugin.<id>.*), para os plugins não conseguirem ler nem atropelar uns aos outros:
ctx.storage.set('lastTab', 'board')
const tab = ctx.storage.get('lastTab', 'summary')
ctx.storage.remove('lastTab')
Plugins empacotados
Um plugin pode ser entregue dentro da árvore, em apps/desktop/src/plugins/<id>/plugin.tsx (com
exportação padrão de um HermesPlugin). Ele é descoberto pelo discoverBundledPlugins() no boot —
sem import, sem edição de registro — e compartilha exatamente o mesmo contrato de inventário +
habilitar/desabilitar ao vivo de um plugin de disco. As duas diferenças:
- Ele passa pelo build Vite do aplicativo, então você pode escrever JSX de verdade e importar
o SDK pelo apelido
@hermes/plugin-sdk. - Ele continua cercado por lint a
@hermes/plugin-sdk+reactsó — sem partes internas do aplicativo por@/….
Hoje não há plugin de desktop entregue na árvore do núcleo; o aplicativo entregue fica sem bagunça
e as demonstrações ficam no repositório companheiro
hermes-example-plugins.
Modelo de segurança
Um plugin carregado é avaliado como ESM no domínio do renderer, com autoridade total do
aplicativo — o singleton do React, o SDK inteiro (RPC de gateway por host.request,
ctx.rest, armazenamento, navigate). O isolamento que o carregador oferece é só isolamento
de erro: um plugin não consegue derrubar o aplicativo (as contribuições têm fronteira de erro,
os ouvintes são isolados), mas consegue fazer tudo que o aplicativo faz.
Isso é aceitável para origens locais — um arquivo em disco já pode rodar código na
sua máquina — e é por isso que a porta de disco só carrega arquivos locais que você (ou o seu
agente) escreveu. A checagem opcional de integrity (sha256-…) só prova que os bytes
batem com um hash; ela não cria sandbox. Uma futura porta de origem remota vai precisar de uma
fronteira de verdade (iframe/worker + CSP + controle de capacidades) antes de poder existir; não
trate esta esteira como fronteira de confiança.
Armadilhas
- JSX não é interpretado num plugin de disco. O arquivo é carregado sem compilar — use
jsx()/jsxs()(ouReact.createElement), não a sintaxe JSX. (Plugins empacotados passam por build, então JSX funciona lá.) - Só três especificadores resolvem:
@hermes/plugin-sdk,reactereact/jsx-runtime. Qualquer outro import vira um erro de carregamento logo de cara. - Nunca fixe cores no código (
#000,black,rgb(...)). Não mexa no fundo; use variáveis de tema (var(--ui-*)) para tudo. - Referencie só o que você importou. Um componente que você esqueceu de importar (por exemplo,
StatusDot) é umReferenceErrorno render — confira que todo identificador nas suas chamadas dejsx()aparece na linha de import. - Leia o estado de forma imperativa nos handlers (
$atom.get()), nunca de um fechamento de render — senão, eventos rápidos vão ver valores velhos. Assine (useValue) só na folha que renderiza o valor. - Painéis de canvas precisam acompanhar o contêiner com um
ResizeObservere redimensionar o canvas (pelos atributos de width/height, não só por CSS) — os painéis mudam de tamanho o tempo todo. - Não faça polling mais rápido que alguns segundos com o
host.request; prefirahost.onEvent/ctx.sockete deixe o React Query deduplicar. - O
ctx.socketé um no-op em remotos por OAuth. Tenha sempre um plano B por polling.
Referência
Exportações do SDK num relance
| Categoria | Exportações |
|---|---|
| Host | host (.state.*, .notify, .notifyError, .navigate, .onEvent, .logs, .status, .restartGateway, .request) |
| Contrato de plugin | HermesPlugin, PluginContext, PluginContribution, PluginStorage, PluginOs, PluginRestOptions, PluginNativeNotificationInput, PluginNotificationAction, HermesOpenTarget, Contribution |
| Constantes de área | PANES_AREA, ROUTES_AREA, SIDEBAR_NAV_AREA, STATUSBAR_AREAS, TITLEBAR_AREAS, PALETTE_AREA, KEYBINDS_AREA, THEMES_AREA, COMPOSER_AREAS |
| Cargas úteis de área | RouteContribution, SidebarNavContribution, StatusbarItem, TitlebarTool, PaletteContribution, KeybindContribution, ComposerMiddleware, ComposerAttachmentProvider |
| React / estado | useValue, atom, computed, useQuery, useMutation, useQueryClient, queryClient, Contribute |
| Tematização | useTheme, requestTheme, setAccentOverride, $accentOverride, retintTheme, themeHue, DesktopTheme, DesktopThemeColors, além da matemática OKLCH (hexToOklch, oklchToHex, oklchToSrgb255, mixOklab, maxChroma, hueDelta, contrastRatio, readableOn, normalizeHex) |
| Kit de interface | Button, Input, Textarea, Select*, Switch, Checkbox, SegmentedControl, Tabs*, Dialog*, ConfirmDialog, DropdownMenu*, ContextMenu*, Popover*, Tip/Tooltip*, Badge, Kbd/KbdGroup, SearchField, ScrollArea, Separator, Skeleton, GlyphSpinner, Loader, EmptyState, ErrorState, CopyButton, StatusDot, LogView, Codicon, DecodeText |
| Auxiliares | cn, icons, haptic, useI18n, profileColor, profileColorSoft, relativeTime, fmtDateTime, fmtDayTime, coarseElapsed, evaluateRuntimeReadiness |
A lista canônica e sempre atual de exportações é apps/desktop/src/sdk/index.ts.
Agentes: a skill hermes-desktop-plugins
Quando um agente escreve um plugin de desktop, ele deve carregar a skill embutida
hermes-desktop-plugins — ela carrega o mesmo contrato desta página em
forma voltada ao agente, com um templates/plugin.js pronto para copiar. Esta página é a
referência humana/de desenvolvedor; a skill é a lista de verificação de trabalho.
Resolução de problemas
O meu plugin não aparece. Confirme que o arquivo está em
$HERMES_HOME/desktop-plugins/<id>/plugin.js e que o nome da pasta bate com o
id exportado. Rode ⌘K → Reload desktop plugins. Veja se o aplicativo mostrou um toast de
erro nomeando a falha e acompanhe o hermes logs gui -f.
"unsupported import" no carregamento. Um plugin de disco só pode importar
@hermes/plugin-sdk, react e react/jsx-runtime. Tire qualquer outro import.
Um elemento jsx não renderiza nada / lança ReferenceError. Um identificador usado
numa chamada de jsx() não foi importado. Acrescente-o à linha de import.
O ctx.rest devolve 404. O backend não está montado: confirme que o
~/.hermes/plugins/<id>/dashboard/manifest.json tem "api": "plugin_api.py",
que o plugin está em plugins.enabled no config.yaml, e reinicie o gateway
(as rotas de backend são montadas na inicialização). Acompanhe o ~/.hermes/logs/errors.log atrás de
Failed to load plugin <id> API routes.
O ctx.socket nunca dispara. Num remoto por OAuth ele é um no-op de propósito — use o seu
plano B por polling. Fora isso, verifique se o backend expõe a rota
@router.websocket(...) correspondente sob o namespace dele.
As cores ficam erradas depois de trocar o tema. Você fixou uma cor no código. Troque-a por
uma variável de tema var(--ui-*).