Comece no session.start
O session.start dispara uma vez por mod quando a sessão fica pronta, antes do primeiro prompt, e o engine espera por ele. Tudo o que você registra ali já existe no turno 1: um comando, uma ferramenta para o modelo, um timer.
Ele dispara de novo quando o mod é ligado no meio da sessão e quando ele recarrega (só os módulos que mudaram). Não dispara no /clear: ali chega um session.end com reason: 'clear', e nenhum session.start depois.
🆕 Novo aqui? Fora do dispatch
Um dispatch é uma passagem de evento pela cadeia, com orçamento de 10 s (módulo 2.2). "Fora do dispatch" é o trabalho que não cabe nisso: começa num hook, mas segue rodando depois que o hook devolveu. Quem mantém esse trabalho vivo é o relógio ($.clock), não o hook.
'session.start' nos tiposon("session.start", ($, e, next) => $.tool.register(t).then(() => next(e)))
session.start é aguardado, o modelo já vê essa ferramenta na primeira resposta. Ferramentas do mod são o assunto do módulo 3.4.Na pasta do Claude Mods Starter Kit (da Prompt Advisers, licença MIT; o comando responde sem chamar o modelo):
claude -p "/readcount" --plugin-dir templates/starter-mod
Saída real (05/10/2026):
read-counter-example: Successful reads observed: 0
/readcount só existe porque o session.start do starter-mod chamou $.command.register antes desse prompt.Agende com clock.every e clock.after
Dois timers. $.clock.every(ms, fn) chama fn a cada período. $.clock.after(ms, fn) chama uma vez. Os dois devolvem um objeto com cancel(). Eles rodam até você cancelar ou até o mod recarregar.
No reload os timers morrem junto com o ambiente antigo. Como o session.start dispara de novo para o módulo que mudou (segundo os tipos), quem liga o timer ali o religa sozinho. Esse é o motivo de não ligar timer em nenhum outro lugar.
export const register: Register = (on, options) => {
const fixa = String(options.pasta ?? '').trim()
const seg = Number(options.intervalo)
const intervalo = (Number.isFinite(seg) && seg >= 5 ? seg : 30) * 1000
on('session.start', async ($, e, next) => {
await update($, cwdAtom, () => e.cwd)
await $.command.register({
name: 'longrun',
description: 'Painel da execução longa (args: todas | atualizar)',
})
await atualizar($, fixa).catch(() => null)
$.clock.every(intervalo, () => {
void atualizar($, fixa).catch(() => null)
})
return next(e)
})
/config e tem piso de 5 s. A primeira atualização é aguardada; as seguintes rodam com void, cada uma num dispatch de clock.every só seu. O .catch garante que uma falha num ciclo não derrube os próximos.Como ler o desenho: os pontos são as chamadas de fn. Na linha amarela tudo para. O segundo bloco azul é o mesmo hook rodando outra vez, no ambiente novo, e por isso os ticks voltam.
⚠️ Confira o religamento numa sessão real
O guia do kit INEMA marca esse ponto como "não confirmado ao vivo" (pegadinha 17): os tipos dizem que o session.start volta no reload, um agente observou o contrário. Ao fazer um mod com timer, salve o arquivo com o mod carregado e veja se o painel continua atualizando. Outro uso do after: $.command.run dentro de um hook que o turno espera é recusado; dispare com $.clock.after(0, ...) (pegadinha 16).
Leia e escreva arquivos com $.fs
O mod não tem fs do Node. Tem o $.fs, que lê, escreve, lista e examina caminhos. Caminho relativo é relativo à pasta de trabalho da sessão. Quase todo método rejeita quando o arquivo não existe, então o .catch é parte da chamada.
O painel de execução longa do Rafa lê os arquivos de uma pasta longrun/ a cada ciclo. Repare que nenhuma leitura derruba o painel: faltou o arquivo, vira texto vazio ou lista vazia.
async function lerTexto($: EngineInterface, caminho: string) {
return $.fs.read(caminho).catch(() => '')
}
/** A pasta da execução: a fixada nas opções, ou a mais recente (pelo nome) em <cwd>/longrun/. */
async function acharPasta($: EngineInterface, cwd: string, fixa: string): Promise<string | null> {
const base = fixa !== '' ? fixa : cwd
if (fixa !== '' && (await $.fs.exists(juntar(fixa, 'goal.md')).catch(() => false))) return fixa
const dir = juntar(base, 'longrun')
const itens = await $.fs.list(dir).catch(() => [])
// ... filtra kind === 'dir' e escolhe a mais recente pelo nome ...
$ e estão no topo do arquivo. Cada item de $.fs.list traz name, kind, size, mtimeMs e isLink.| Chamada | Devolve | Detalhe dos tipos |
|---|---|---|
$.fs.read(p) | o texto | rejeita se faltar ou passar de 4 MiB |
$.fs.read(p, { as: 'bytes' }) | { base64 } | para arquivo binário |
$.fs.write(p, texto) | nada | cria o arquivo e as pastas; troca o conteúdo inteiro |
$.fs.list(p) | as entradas | sem p, a pasta de trabalho |
$.fs.stat(p, { resolve: true }) | kind, size, mtimeMs, isLink e realPath | realPath com links e .. resolvidos |
✓ Leitura que não derruba
- ✓
.catchem toda chamada de$.fs - ✓ padrão claro quando falta: texto vazio, lista vazia
- ✓ caminho relativo pensado a partir da pasta de trabalho
✗ Leitura frágil
- ✗ supor que o arquivo existe
- ✗ ler arquivo grande inteiro (passa de 4 MiB, rejeita)
- ✗ guardar por lista de nomes proibidos sem
realPath
💡 Guarda de caminho: lista do que pode, sobre realPath
Para um mod que barra escrita fora do projeto, a referência diz qual é a forma robusta: resolver o caminho com stat(..., { resolve: true }) e aceitar só o que fica dentro de uma raiz resolvida do mesmo jeito. Uma lista do que é proibido, comparando texto do caminho, é "melhor esforço": um link simbólico passa por ela.
Rode processos locais com $.process
$.process.run roda um programa da máquina, como o usuário da sessão, e devolve { exitCode, stdout, stderr } quando ele termina. O comando vai como lista de argumentos (argv), sem shell no meio.
Sem shell quer dizer: nada de |, && ou * interpretados, e nada de um texto do modelo virar comando por engano. Cada argumento chega ao programa exatamente como você escreveu.
🆕 Novo aqui? argv
O argv é a lista que um programa recebe ao iniciar: o nome do programa e depois cada argumento separado. ['git', 'status', '--short'] é o mesmo que digitar git status --short, mas sem o shell decidir onde cortar as palavras.
async function git($: EngineInterface, cwd: string, args: string[]) {
const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 5_000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r.stdout.trim() : ''
}
.catch para o caso de o git nem existir, e saída vazia quando o código de saída não é 0. O mod nunca fica preso esperando um programa.| Opção ou limite | Valor nos tipos |
|---|---|
cwd, env, stdin | opcionais; cwd padrão é o da sessão |
timeoutMs | 30 s por padrão, 10 minutos no máximo |
| saída | até 4 MiB por stream; isStdoutTruncated avisa o corte |
| quando rejeita | não conseguiu iniciar, ou ainda rodava no fim do prazo |
git | roda com os hooks do repositório desligados |
✓ Bom uso
- ✓ Programas de leitura:
git status,git log - ✓ Abrir uma pasta (o
abridordo recibo, vindo do/config) - ✓ Saída longa e contínua:
$.process.spawn, que é stream
✗ Evite
- ✗ Montar um
sh -ccom texto vindo do modelo - ✗ Programa que fica rodando em segundo plano com
run - ✗ Chamar algo que acessa a rede (regra do kit INEMA)
Acorde a sessão com prompt.submit
Um timer descobriu algo e o Claude está parado esperando. O mod pode acordar a sessão: $.prompt.submit({ text }) põe um prompt na fila, que vira um turno próprio assim que a sessão fica livre.
Ele nunca entra no meio de um turno que está rodando. E a promessa resolve quando o turno começa, não quando termina. Por isso os tipos usam void na frente: o mod pede e segue.
O mod chama $.prompt.submit
De um timer, de um comando ou de um botão. O prompt entra na fila.
Passa pela cadeia de prompt.submit
Os outros hooks veem e.origin como { kind: 'plugin', name }: sabem que veio de um mod.
A sessão fica livre e o turno começa
Aqui a promessa resolve. O modelo lê o texto como mensagem enviada pelo mod.
// do comentário de prompt.submit nos tipos: começa um turno (gasta modelo)
void $.prompt.submit({ text: "List the TODOs you just mentioned." })
// da referência (comando /quote): só preenche a caixa; a pessoa decide
await $.prompt.fill({ text: quoted + '\n\n', mode: 'insert' })
submit dispara um turno sozinho. O fill só escreve na caixa do prompt e espera o Enter da pessoa. No kit INEMA, a segunda é o padrão: o mod sugere, a pessoa decide gastar.⚠️ Timer + submit = gasto sem ninguém olhando
Um $.clock.every que chama $.prompt.submit faz o Claude trabalhar sozinho, em laço, enquanto a pessoa está fora. Se usar, ponha um limite claro, mostre na tela que está ligado e deixe um comando para desligar.
Decida se o mod pode gastar modelo
O $ tem um noun model. $.model.complete({ model, prompt }) faz uma pergunta avulsa, sem histórico. $.model.fork({ prompt }) pergunta sobre a própria conversa, aproveitando o cache. As duas usam o cliente da sessão: gastam a cota da pessoa.
Elas sempre resolvem com um resultado, nunca com texto solto: isAnswered com text e usage, ou isAnswered: false com um reason (api-error, empty-reply, aborted; no fork, também nothing-to-fork).
| Chamada | Gasta cota? | Kit INEMA |
|---|---|---|
$.model.complete, $.model.fork | sim | proibido |
$.http.fetch | rede, pode ser serviço pago | proibido |
$.prompt.submit | sim, um turno inteiro | só a pedido da pessoa |
$.prompt.fill, $.fs, $.process local, $.ui | não | liberado |
Como ler o desenho: comece em cima. A maior parte dos mods para no ramo da esquerda. Gastar modelo só aparece quando a pessoa pediu, e mesmo assim por um caminho que ela vê. A linha vermelha é a regra do kit INEMA: um mod nunca chama o modelo por conta própria.
Na pasta do Claude Mods Starter Kit:
claude plugin validate templates/starter-mod
Saída real (05/10/2026):
Validating plugin manifest: .../templates/starter-mod/.claude-plugin/plugin.json
Validating hooks: .../templates/starter-mod/hooks/hooks.json
❯ ./starter.mjs hooks: session.start, tool.call{tool=Read}, command.run{command=readcount}
❯ ./starter.mjs calls: $.command.register
✔ Validation passed
calls: lista tudo o que o mod pede ao engine. Aqui só aparece $.command.register: nada de model, http ou prompt.submit. Rode o mesmo comando na pasta de qualquer mod de terceiro antes de instalar.Teste rápido (opcional): o Rafa quer que um painel releia um arquivo a cada 30 segundos, a sessão inteira. Onde ele liga isso?
🎓 Resumo do módulo
Próximo módulo:
3.1 — ui.render e as superfícies (Trilha 3: Telas e comandos)