Flag probe: on session start, shows a notice and logs a line to ~/.claude/mods-flag.log, proving hooks modules are ON in this process

A flag tracker for Claude Code hooks modules ("mods"). Mods load only while the server-side GrowthBook flag tengu_plugin_hooks_modules is ON for the process; while it is off, every hooks module is silently skipped. This plugin turns that silence into evidence.
On session.start its module does two things:
mods-probe: hooks modules are ON in this process (logged to ~/.claude/mods-flag.log). A transcript line ($.ui.log) was chosen over a toast: a toast lasts 4 s and can be missed or not yet drawn at launch, while the line persists, and -p/SDK/desktop hosts receive it as ui_log.~/.claude/mods-flag.log: 2026-10-01T12:34:56.789Z session=<id|?> surface=<terminal|desktop|vscode|mobile|none> interactive=<true|false> version=<claude version|?> on
surface=none is normal for a host that draws nowhere yet at start (some SDK/desktop sessions).
The module never runs while the flag is off, so the probe alone can only say "on": silence from it means off or unknown. The "off" half comes from a companion SessionStart command hook, hooks/mods-flag-reconcile.ts in the ~/.claude repo (wired in ~/.claude/settings.json, run with /opt/homebrew/bin/bun). Settings hooks are not gated by the flag, so it runs in every process and appends to the same log:
2026-10-01T12:34:56.000Z session=<id> source=<startup|resume|fork> pid=<claude pid|?> start
2026-10-01T12:34:56.789Z session=<id> surface=terminal interactive=true version=2.1.287 on # this plugin
2026-10-01T12:38:10.000Z session=<id> pid=<claude pid|?> for_start=2026-10-01T12:34:56.000Z inferred off
One grammar for all three: <ISO time> key=value... <state>, the last token is the state (start, on, off), session= is required. The rules:
start. /clear and compact keep the process and the probe's session.start does not fire for them, so they write nothing. A resume writes a start only for a new process (a pid with no start since it launched).start older than 2 minutes with no on for the same session from 30 s before to 5 minutes after it gets one inferred off line on the next session start in any process. That line is also the marker that stops it being inferred again. The on of a session whose id the probe could not read (session=?) counts for any start near it.on = mods were on; inferred off = the process started and never logged on. A start younger than 2 minutes is still pending.Report (read-only, also counts starts that are due an off but not yet marked; days are UTC, an off counts on its start's day):
bun ~/.claude/hooks/mods-flag-reconcile.ts --report
# day starts on off
# 2026-10-01 6 4 2
# total 6 4 2
# latest: off at 2026-10-01T12:34:56.000Z
# last transition: on -> off at 2026-10-01T12:34:56.000Z
Raw views (only on/off lines count; start lines are the denominator):
tail -n 20 ~/.claude/mods-flag.log
grep ' on$' ~/.claude/mods-flag.log | cut -dT -f1 | sort | uniq -c # on-sessions per day
grep ' off$' ~/.claude/mods-flag.log | cut -dT -f1 | sort | uniq -c # inferred offs per day (by inference time)
Limits. A reload of the module while a session runs (hot reload, enable) raises session.start again, so a session can occasionally log on twice. The probe trims the file to its newest 2000 lines on each on, by read-modify-write, so a start the hook appended during that instant can be lost (harmless: a lost start never produces an off). Two probes writing in the same instant can lose an on, which would show as a false off. The hook never rewrites the file except to trim an oversized one (over 1 MiB, only when mods have been off long enough for the probe not to trim), by an atomic rename. If the probe does not report the resumed session's id the way the hook sees it, a resumed process can look like an off. A claude pid that cannot be found (a sandboxed ps) is logged as pid=?.
Cross-check the cached flag value with jq '.cachedGrowthBookFeatures.tengu_plugin_hooks_modules' ~/.claude.json.
Installed from the craigs-claude-plugins marketplace and enabled in ~/.claude/settings.json (enabledPlugins), which covers every session on this machine in any directory: the CLI (with or without claude-smart.sh) and the desktop app, which share user settings. claude-smart.sh --plugin-dir is not used: it only loads plugins by project type and never reaches the desktop app.
After editing the module, bump version in .claude-plugin/plugin.json, then claude plugin marketplace update craigs-claude-plugins && claude plugin update mods-probe@craigs-claude-plugins and restart sessions. See my-plugins/MODS-ACTIVATION.md.
claude plugin validate my-plugins/mods-probe
DISABLE_GROWTHBOOK=1 claude plugin test my-plugins/mods-probe # one-off only: never put this in a launcher or settingshooks/register.ts 55 lines1import type { Register } from 'claude-code'
2import { LOG_RELATIVE_PATH, NOTICE, appendLine, formatLine } from './probe'
3
4/**
5 * Flag probe. This module only runs when the server-side `tengu_plugin_hooks_modules` flag is ON for the process
6 * (or DISABLE_GROWTHBOOK=1 forces it), so reaching `session.start` IS the signal.
7 *
8 * Notice: `$.ui.log`, a transcript line. A toast was rejected: it lasts 4 s, floats over the transcript's corner
9 * (one notification-bar line in scrollback mode) and can be missed or never drawn before the first frame at
10 * launch, while a transcript line persists and a `-p` or SDK/desktop host receives it as `ui_log`.
11 *
12 * Every step is isolated: a failing id, version, read or write never stops the others, and never the session.
13 */
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 try {
17 $.ui.log(NOTICE)
18 } catch {
19 // the log line below still records the fact
20 }
21
22 const sessionId = await $.session.id().catch(() => undefined)
23 const version = await $.session
24 .version()
25 .then(v => v.version)
26 .catch(() => undefined)
27 const nowMs = await $.clock.now()
28 const line = formatLine({
29 nowMs,
30 sessionId,
31 surface: e.surface,
32 isInteractive: e.isInteractive,
33 version,
34 })
35
36 try {
37 const home = await $.env.get('HOME')
38 if (home !== undefined && home !== '') {
39 const path = `${home}/${LOG_RELATIVE_PATH}`
40 // $.fs has no append: read, add one line, write back. Two sessions starting in the same instant can lose
41 // a line; for a flag tracker that is acceptable.
42 const existing = await $.fs.read(path).then(
43 text => (typeof text === 'string' ? text : ''),
44 () => '',
45 )
46 await $.fs.write(path, appendLine(existing, line))
47 }
48 } catch {
49 // never block the session over a log line
50 }
51
52 return next(e)
53 })
54}
55hooks/probe.ts 49 lines1// Pure helpers for the mods-probe hooks module. No `claude-code` imports, so they are testable as plain functions.
2
3/** Where the probe appends, relative to $HOME. */
4export const LOG_RELATIVE_PATH = '.claude/mods-flag.log'
5
6/** The log is trimmed to its newest this-many lines on every append, so it cannot grow without bound. */
7export const MAX_LOG_LINES = 2000
8
9/** The line shown in the transcript at session start. */
10export const NOTICE = 'mods-probe: hooks modules are ON in this process (logged to ~/.claude/mods-flag.log)'
11
12export type ProbeFacts = {
13 /** Milliseconds since the epoch, from `$.clock.now()`. */
14 readonly nowMs: number
15 /** `$.session.id()`, or undefined when it could not be read. */
16 readonly sessionId: string | undefined
17 /** `session.start`'s `e.surface`; null for a `-p` or SDK/desktop-host run that draws nowhere yet. */
18 readonly surface: string | null
19 /** `session.start`'s `e.isInteractive`. */
20 readonly isInteractive: boolean
21 /** `$.session.version().version`, or undefined when it could not be read. */
22 readonly version: string | undefined
23}
24
25/** Strips whitespace and control characters so one fact can never break the one-line, space-separated format. */
26const field = (value: string): string => value.replace(/[\s\u0000-\u001f\u007f]+/gu, '_')
27
28/**
29 * One log line: `<ISO time> session=<id|?> surface=<terminal|desktop|...|none> interactive=<true|false>
30 * version=<v|?> on`. The trailing `on` is the only state this probe can report: the module is never loaded while
31 * the flag is off, so a line existing means "on".
32 */
33export const formatLine = (facts: ProbeFacts): string =>
34 [
35 new Date(facts.nowMs).toISOString(),
36 `session=${facts.sessionId === undefined ? '?' : field(facts.sessionId)}`,
37 `surface=${facts.surface === null ? 'none' : field(facts.surface)}`,
38 `interactive=${facts.isInteractive}`,
39 `version=${facts.version === undefined ? '?' : field(facts.version)}`,
40 'on',
41 ].join(' ')
42
43/** Appends `line` to `existing` (the file's text, '' when missing) and keeps only the newest MAX_LOG_LINES lines. */
44export const appendLine = (existing: string, line: string): string => {
45 const lines = existing.split('\n').filter(l => l !== '')
46 lines.push(line)
47 return lines.slice(-MAX_LOG_LINES).join('\n') + '\n'
48}
49