Architecture Module variables, $.state, $.store
What lasts how long
A mod can keep a value in three places, and each one forgets at a different moment. Most mod bugs we have seen, including one in our own sample, come from a value that lived one tier shorter than its author thought.
The three tiers
| Module variable | $.state | $.store | |
|---|---|---|---|
| What it is | Plain JS in your file, shared by every hook in it | Named, versioned values Claude Code holds for the session | A JSON file per plugin under ~/.claude/plugins/store/ |
| Scope | One module instance | One session | Every session on the machine for this plugin |
| Survives a hot reload | No | Yes | Yes |
Survives /clear, /resume, /branch | Yes, if no reload happens | No: reset to defaults | Yes |
| Survives a new session | No | No | Yes, until deleted or unused for cleanupPeriodDays |
| Redraws readers | No; call $.ui.invalidate | Yes, exactly the sites that read it | No |
| Limits | Memory | Declared in PluginState; render hooks can't write | 4 MiB per plugin; get and set not atomic across sessions |
Timers from $.clock.every and $.clock.after live as long as module variables: they're dropped on every reload. Files written with $.fs last as long as the disk, with 4 MiB per file and non-atomic writes.
What triggers each reset
| Moment | Module vars and timers | $.state | $.store | Events you get |
|---|---|---|---|---|
Save a watched file, /reload-plugins, change an option | Lost | Kept | Kept | register runs again in a fresh environment, then session.start |
/clear | Kept | Reset | Kept | session.end with reason: 'clear', then classic.SessionStart with source: 'clear'. No session.start. |
/resume, /branch | Kept | Reset | Kept | classic.SessionStart with source: 'resume' or 'fork' |
| Hooks worker lost and restarted | Lost | Lost | Kept | The hook that was running runs again in the fresh copy |
| Quit Claude Code | Lost | Lost | Kept | session.end; all such hooks share 1.5 s |
The trap, and the fix
Our ledger sample counts turns and tool calls for this session and for all time. It showed both in a band above the prompt. It kept the all-time tally in $.state so the band redrew on each change, and in $.store so the tally outlived the session. It loaded the store into state in session.start.
Then someone ran /clear. $.state went back to zero, and session.start didn't fire, so nothing reloaded it. The next tool call read zero from state, added one and saved 1 over the real total in the store.
Before
Every write starts from $.state. State is treated as the truth and the store as a backup.
After
Every write starts from $.store. State is only a mirror for drawing. A classic.SessionStart hook reloads the mirror after clear, resume and fork.
async function bump($: EngineInterface, field: keyof Tally) {
const before = ((await $.store.get(KEY)) as Tally | undefined) ?? ZERO // the truth
const total = { ...before, [field]: before[field] + 1 }
await $.store.set(KEY, total)
await update($, allTime, () => total) // the mirror
}
on('classic.SessionStart', async ($, e, next) => {
if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
await update($, allTime, async () => ((await $.store.get(KEY)) as Tally | undefined) ?? ZERO)
}
return next(e)
})
One more catch: in 2.1.293 a fix landed for a mod's classic.* hooks being skipped while the hooks worker restarted. A mod that depends on classic.SessionStart for correctness could have shown this bug now and then before that release.
Rules
- Anything a drawing reads goes in
$.state. A module variable won't redraw anything, and a reload loses it. - Anything that must outlive the session goes in
$.store, and the store is the source of truth. Read it before every write. - Reload mirrors in
classic.SessionStartforclear,resumeandfork. Don't rely onsession.startalone. - The store is shared by every session on the machine. Two terminals running your mod race on one file. Use per-item keys, such as one key per session id, and re-read before you write.
- Timers are per load. Start them in
session.start, which runs again after a reload, never at the module's top level. - Save early. Every
session.endhook together gets 1.5 seconds. Write to the store as things happen rather than at exit.