Pular para o conteúdo
MÓDULO 2.3

💾 Estado que sobrevive

Variável some no reload. Você salva o arquivo do mod, ele recarrega, e o contador volta a zero. Este módulo mostra onde guardar cada dado para ele durar o tempo certo: até o reload, até o fim da sessão ou para sempre.

6
Tópicos
~35
Minutos
Médio
Nível
Prático
Tipo
0 de 60%
1

Veja a variável de módulo sumir

O starter-mod do módulo 1.4 guarda o contador numa variável: let count = 0, dentro do register. Funciona, até você salvar o arquivo. No reload, o register roda de novo num ambiente novo, e a variável nasce zerada.

Isso não é defeito. É o jeito de o código novo entrar sem reiniciar a sessão. Mas tudo o que estava só na memória do módulo vai embora junto: variáveis, timers, caches. Vale a pena ver acontecer uma vez.

🆕 Novo aqui? Hot-reload e variável de módulo

Hot-reload é a recarga do mod quando você salva um arquivo da pasta vigiada (módulo 1.3). Variável de módulo é qualquer let ou const que vive no arquivo do mod ou dentro do register: ela existe só enquanto aquele ambiente existir.

🎯 Objetivo: ver o contador do starter-mod zerar no reload

Na pasta do Claude Mods Starter Kit (da Prompt Advisers, licença MIT), abra uma sessão com o mod carregado da pasta:

claude --plugin-dir templates/starter-mod

Dentro da sessão, um passo de cada vez:

Leia o README.md e me diga o título.
/readcount
(no editor: acrescente um espaço no comentário da 1ª linha de templates/starter-mod/hooks/starter.mjs e salve)
/readcount
Como verificar: o primeiro /readcount mostra pelo menos uma leitura. Depois de salvar, o segundo /readcount volta para 0 (resultado esperado, não medido nesta máquina). Se não voltar, confira se a sessão foi aberta com --plugin-dir: só pasta vigiada recarrega.

✓ Pode ficar em variável

  • ✓ Constantes e opções lidas do options
  • ✓ Contas de um turno só, como o startedAt e o tools do band.tsx
  • ✓ Algo que, se zerar, ninguém nota

✗ Não pode ficar em variável

  • ✗ O que a tela desenha
  • ✗ Listas que crescem na sessão, como o recibo
  • ✗ Preferências da pessoa (ligado/desligado)
2

Declare o contrato em types/index.d.ts

O lugar certo para o que precisa sobreviver ao reload é o $.state: valores que o host guarda durante a sessão, cada um com nome e versão. Antes de usar, você declara o formato num arquivo de tipos do mod.

Esse arquivo é o contrato. Ele diz ao TypeScript, ao claude plugin validate e a outros mods quais chaves o seu mod guarda e de que tipo. O plugin.json aponta para ele com "types".

🆕 Novo aqui? PluginState

PluginState é uma interface do módulo 'claude-code' que cada mod amplia com declare module. A chave de fora é o nome do mod; dentro dela, cada chave é um valor guardado.

📄 inema-mods/mods/recibo-sessao/types/index.d.ts (inteiro)
export type Entrada = {
  caminho: string
  acao: 'criado' | 'editado'
  turno: number
  hora: number
}

declare module 'claude-code' {
  interface PluginState {
    'recibo-sessao': { entradas: Entrada[]; turno: number }
  }
}

E a linha no .claude-plugin/plugin.json do mesmo mod:

"types": "./types/index.d.ts",
O que olhar: o arquivo exporta um tipo (Entrada) e o item do mod ('recibo-sessao': { ... }) é um objeto escrito ali mesmo. As duas coisas são exigências, não estilo.

⚠️ Duas pegadinhas do kit INEMA

13: o contrato precisa exportar ao menos um tipo; um export {} vazio faz o validate falhar. 21: em PluginState, o item do mod tem de ser um objeto literal; apontar para um tipo com nome ('recibo-sessao': MeuEstado) dá "not declared".

🎯 Objetivo: provar que o contrato bate com o código

Na pasta do inema-mods, troque <caminho do tsc> pelo tsc da sua máquina:

TSC=<caminho do tsc> scripts/checar-mod.sh mods/recibo-sessao

Saída real (05/10/2026):

== recibo-sessao
  validate: ok
  tsc: ok
  test: ok (6 linhas de aprovação)
Como verificar: as três linhas terminam em ok. A linha tsc é a que confere o contrato: se você mudar turno: number para turno: string no index.d.ts, ela deixa de dar ok. Desfaça a mudança depois do teste.
3

Use atom, read e update

Dá para usar $.state.get e $.state.set direto. Mas o próprio 'claude-code' traz três funções que deixam o código curto e seguro: atom, read e update. O exemplo oficial da faixa usa as três.

Repare no desenho do arquivo: o que a tela mostra (last, isHidden) está em átomos. As contas do turno (startedAt, tools) estão em variáveis, porque perder isso num reload não faz falta.

🆕 Novo aqui? As três funções

  • atom(ref, inicial) — um valor com nome ({ plugin, key }, os dois literais) e valor inicial. Declare no topo do arquivo.
  • read($, átomo) — lê o valor; dentro de um ui.render, também inscreve aquele desenho para ser refeito quando o valor mudar.
  • update($, átomo, fn) — lê, aplica fn e grava com checagem de versão; se outro escreveu no meio, tenta de novo.
📄 plugin-authoring/examples/band.tsx (exemplo oficial, inteiro)
import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'

import type { Turn } from '../types'

const last = atom({ plugin: 'turn-band', key: 'last' } as const, null)
const isHidden = atom({ plugin: 'turn-band', key: 'isHidden' } as const, false)

export const register: Register = on => {
  let startedAt = 0
  let tools = 0

  on('prompt.submit', async ($, e, next) => {
    startedAt = await $.clock.now()
    tools = 0

    return next(e)
  })

  on('tool.call', ($, e, next) => {
    tools += 1

    return next(e)
  })

  on('turn.complete', async ($, e, next) => {
    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
    const turn: Turn = { seconds, tools }
    await update($, last, () => turn)

    if (seconds > 120) {
      $.ui.toast(`That turn took ${seconds}s`)
    }

    return next(e)
  })

  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    const turn = await read($, last)
    const isQuiet = e.props.hasSurvey || turn === null || (await read($, isHidden))

    if (isQuiet) {
      return next(e)
    }

    const { Box, Button, Text } = $.ui.resolve(e)

    return (
      <Box>
        <Text dimColor>
          Last turn: {turn.seconds}s, {turn.tools} tool calls{' '}
        </Text>
        <Button
          key="hide"
          label="Hide"
          onPress={() => update($, isHidden, () => true)}
        />
      </Box>
    )
  })
}
O que olhar: o turn.complete escreve com update; o ui.render só lê com read; o botão escreve de dentro do onPress. Ninguém chama $.ui.invalidate: a leitura no desenho já inscreve a faixa. O contrato está em examples/band-state.d.ts, igual ao do recibo.
1

Declare o átomo no topo

atom({ plugin, key } as const, inicial), com plugin e key literais, iguais ao contrato.

2

Escreva num evento

update($, last, () => turn) no turn.complete.

3

Leia no desenho

read($, last) no ui.render inscreve a faixa naquele valor.

4

Escreva num handler

O onPress chama update; o host redesenha quem leu, sem $.ui.invalidate.

turn.complete onPress do botão update $.stateguardado pelo host read ui.renderdesenha a faixa ✗ nunca escreve ao desenhar

Como ler o desenho: as setas azuis da esquerda são escritas, vindas de eventos ou de botões. A seta da direita é leitura. A linha vermelha é proibida: um $.state.set durante o desenho é negado pelo engine. Escreva sempre de um evento ou de um handler.

4

Guarde entre sessões com $.store

O $.state sobrevive ao reload, mas é da sessão. Fechou o Claude Code, acabou. Para durar entre sessões existe o $.store: um arquivo JSON só do seu mod, na pasta de configuração do Claude Code da pessoa.

O padrão do kit INEMA junta os dois: no session.start, lê do $.store e põe no átomo; quando a pessoa muda a preferência, grava nos dois. O desenho continua lendo só do átomo.

📄 inema-mods/mods/bichinho/hooks/register.tsx (trechos)
async function carregarLigado($: EngineInterface) {
  const salvo = await $.store.get(CHAVE_LIGADO).catch(() => undefined)
  await update($, ligado, () => salvo !== false)
}

// ... no command.run do /bichinho:
    if (arg === 'off' || arg === 'on') {
      const valor = arg === 'on'
      await update($, ligado, () => valor)
      await $.store.set(CHAVE_LIGADO, valor)
      return { text: valor ? 'Bichinho acordado.' : 'Bichinho foi dormir (use /bichinho on para acordar).' }
    }
O que olhar: carregarLigado está no topo do arquivo e é chamada no session.start. Se não há nada salvo, o padrão é ligado (salvo !== false). O .catch evita que um store ilegível derrube o início da sessão.
MétodoFazDetalhe dos tipos
$.store.get(chave) lêundefined se nunca gravou
$.store.set(chave, valor) gravasó JSON; até 4 MiB no total
$.store.delete(chave) apaga—
$.store.keys() lista as chavesna ordem em que foram criadas

💡 O que volta não é o que foi

O get devolve o valor depois de passar por JSON: uma Date volta como texto ISO, um Map ou Set volta como {}, campo undefined some. Guarde números e textos simples. E lembre: o que você grava testando ao vivo fica gravado; volte ao padrão depois.

5

Use o relógio do engine

Muito estado tem hora: quando o arquivo foi editado, quanto o turno durou. A tentação é Date.now(). Não use. A hora vem de await $.clock.now(), que devolve os milissegundos do relógio do engine.

O motivo aparece no teste (módulo 4.2): o claude plugin test controla esse relógio. Ele avança o tempo na hora que o teste quer, e o resultado é sempre o mesmo. Com Date.now(), o teste depende da hora em que roda. É a pegadinha 7 do kit INEMA.

📄 inema-mods/mods/recibo-sessao/hooks/register.tsx (trecho)
async function registrar($: EngineInterface, caminho: string, acao: Entrada['acao']) {
  const n = await read($, turno)
  const hora = await $.clock.now()
  await update($, entradas, lista => [...lista, { caminho, acao, turno: n, hora }].slice(-500))
}
O que olhar: três coisas boas em quatro linhas. A função recebe $ e está no topo. A hora vem do engine. E a lista tem teto (.slice(-500)), para o estado não crescer sem fim numa sessão longa.

✓ Faça

  • ✓ const agora = await $.clock.now()
  • ✓ Guardar a hora como número no estado
  • ✓ Converter para texto só na hora de mostrar

✗ Evite

  • ✗ Date.now() ou new Date() sem argumento
  • ✗ Guardar Date no $.store (volta como texto)
  • ✗ Testar data perto da meia-noite: use meio-dia UTC (pegadinha 23)
6

Escolha onde cada dado mora

Agora são três lugares, e cada um dura um tempo. A escolha é uma pergunta só: se isso sumir, quem percebe? Ninguém: variável. A tela: $.state. A pessoa amanhã: $.store, com cópia no $.state se a tela também mostra.

reload fim da sessão 1 sessão 1sessão 2 variável $.state $.store

Como ler o desenho: cada barra é quanto um dado vive. A cinza morre no reload (amarelo). A azul passa pelo reload e morre no fim da sessão (vermelho). Só a última chega à sessão 2.

Dado do RafaLugarPor quê
hora em que o turno começouvariávelvale só até o fim do turno
arquivos editados nesta sessão$.statea tela mostra; é desta sessão
faixa ligada ou desligada$.store + $.statepreferência que a tela também lê
um relatório para abrir depoisarquivo, com $.fs.write é para a pessoa ler fora do Claude (módulo 2.4)
🎯 Objetivo: ver que o $.state nasce vazio a cada sessão

Na pasta do inema-mods (o comando responde sem chamar o modelo):

claude -p "/recibo" --plugin-dir mods/recibo-sessao

Saída real (05/10/2026):

recibo-sessao: Nesta sessão: nenhum arquivo criado ou alterado.
Como verificar: a resposta diz "nenhum arquivo", mesmo que você tenha editado arquivos numa sessão anterior. Cada claude -p é uma sessão nova, e o recibo guarda a lista em $.state, não em $.store. É a escolha certa para um recibo "desta sessão".

Teste rápido (opcional): o Rafa quer um botão "esconder" na faixa que continue escondido amanhã. Onde ele guarda esse liga/desliga?

🎓 Resumo do módulo

✓
Variável de módulo morre no reload — serve só para o que ninguém sente falta.
✓
O contrato vem antes — types/index.d.ts, "types" no plugin.json, um tipo exportado, objeto literal.
✓
atom, read, update — eventos e botões escrevem; o desenho só lê.
✓
$.store atravessa sessões — JSON, carregado no session.start para o átomo.
✓
A hora vem de $.clock.now() — nunca de Date.now().

Próximo módulo:

2.4 — Trabalho fora do dispatch