Hook Chain

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.

Three storage objects on a workbench. A robot wipes a chalk slate labelled MODULE VARIABLE. A whiteboard on an easel with an hourglass and a broom is labelled $.STATE. A leather ledger in an open iron safe with a calendar behind it is labelled $.STORE.

The three tiers

Module variable$.state$.store
What it isPlain JS in your file, shared by every hook in itNamed, versioned values Claude Code holds for the sessionA JSON file per plugin under ~/.claude/plugins/store/
ScopeOne module instanceOne sessionEvery session on the machine for this plugin
Survives a hot reloadNoYesYes
Survives /clear, /resume, /branchYes, if no reload happensNo: reset to defaultsYes
Survives a new sessionNoNoYes, until deleted or unused for cleanupPeriodDays
Redraws readersNo; call $.ui.invalidateYes, exactly the sites that read itNo
LimitsMemoryDeclared in PluginState; render hooks can't write4 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

MomentModule vars and timers$.state$.storeEvents you get
Save a watched file, /reload-plugins, change an optionLostKeptKeptregister runs again in a fresh environment, then session.start
/clearKeptResetKeptsession.end with reason: 'clear', then classic.SessionStart with source: 'clear'. No session.start.
/resume, /branchKeptResetKeptclassic.SessionStart with source: 'resume' or 'fork'
Hooks worker lost and restartedLostLostKeptThe hook that was running runs again in the fresh copy
Quit Claude CodeLostLostKeptsession.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

  1. Anything a drawing reads goes in $.state. A module variable won't redraw anything, and a reload loses it.
  2. Anything that must outlive the session goes in $.store, and the store is the source of truth. Read it before every write.
  3. Reload mirrors in classic.SessionStart for clear, resume and fork. Don't rely on session.start alone.
  4. 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.
  5. Timers are per load. Start them in session.start, which runs again after a reload, never at the module's top level.
  6. Save early. Every session.end hook together gets 1.5 seconds. Write to the store as things happen rather than at exit.