Repository navigation
feat(claude): ship producer hooks as a Claude Code plugin - #214
Conversation
Adding a Mac meant running the installer, which rewrites the user's global Claude Code settings file. A plugin from this repo's own marketplace makes it `claude plugin marketplace add tarakanof/Ember` + `claude plugin install ember@ember`, versions the hook set with the code, and needs no settings surgery. The plugin registers exactly the installer's hooks (one shared producerHookSpecs list; plugin_test.go fails on drift) through a /bin/sh shim that finds the binary (override, PATH, Go bin dir, Ember.app), prints nothing and always exits 0, with 5 s caps on blocking hooks and the same async tool-outcome hooks. producer.env stays the only config source: plugin userConfig would reach the hooks but not the daemon, statusline or app. Claude Code doesn't dedupe a plugin hook against a settings-file one, so both at once would POST every event twice. configure/install now skip and strip the settings-file hooks while ember@ember is enabled (still writing the statusLine, which plugins can't set), and doctor flags a double registration.
Operators need the install, update, uninstall and settings-to-plugin migration steps, and why config stays in producer.env, next to the existing installer instructions.
tarakanof
left a comment
There was a problem hiding this comment.
Review: plugin packaging for producer hooks (#209)
Checked against the current docs (plugins-reference, marketplace-reference, plugin-marketplaces, hooks, settings-reference) and the Claude Code 2.1.288 binary. gofmt -l is clean, and go vet ./... and go test ./... -race pass. The Swift code is unchanged, so I didn't run swift test.
Verified OK: manifest and marketplace schema (owner.name required, ./ relative source resolved from the marketplace root, name ember not reserved). Exec form command + args substitutes ${CLAUDE_PLUGIN_ROOT} into each arg. The enabledPlugins key is <entry-name>@<marketplace>. Plugins can't set statusLine (only agent/subagentStatusLine). The docs confirm that a handler defined in several settings files runs once while a plugin's copy stays separate, so the double-POST reasoning holds. With no version, installs track the commit SHA. async hooks don't have timeout enforced. The shim's stdout is empty and it always exits 0, including with no binary and with an empty or missing EMBER_CLAUDE_PRODUCER. The drift test checks events in both directions plus matcher, group and handler count, exec command/args, async, and the blocking timeout bound.
1. [High, predates this PR] SessionEnd reason is decoded from the wrong field: cmd/ember-claude-producer/hook.go:31
EndReason is tagged json:"end_reason". The hooks docs (SessionEnd input) and the 2.1.288 binary (hook_event_name:"SessionEnd",reason:n) both send reason, so in.EndReason is always "" and the session-end case at hook.go:168 never DELETEs or removes the marker. The tests at hook_test.go:114,135 build the body by marshalling hookInput, so they round-trip the wrong tag and pass anyway.
Failure scenario: on exit, the tick reaps the session within about 10 s (owner pid dead), so that case only adds a delay. On /clear, the claude pid stays alive, so processOneMarker keeps re-POSTing the old session every 10 s until the marker mtime passes EMBER_HEARTBEAT_TTL_HOURS (6 h). That leaves a ghost session on the clock for up to 6 h.
Fix:
EndReason string `json:"reason,omitempty"`Feed a raw {"hook_event_name":"SessionEnd","session_id":"abc","reason":"clear"} string in the tests instead of a marshalled struct. Docs: bypass_permissions_disabled was removed in v2.1.234, so it can be dropped from producerHookSpecs, hooks.json and the switch. Consider adding resume, since interactive /resume also ends the old session while the pid lives on.
2. [Medium] Turning Ember off no longer stops Claude reporting when the plugin is enabled
deconfigure/uninstall (uninstall.go:38-41, via uninstallSettings) only strip settings.json hooks. Ember.app's Agents toggle off runs deconfigure (macos/Sources/EmberKit/ProducerInstallService.swift:218-220). With ember@ember enabled, the plugin hooks keep firing, the shim finds the binary (app bundle or ~/go/bin), and producer.env is still in place (uninstall leaves it on purpose). The hooks therefore keep POSTing and writing markers. After uninstall there is no daemon left to reap those markers. Before this PR, "off" meant silent.
Fix (minimal): in runDeconfigure/runUninstall, when pluginEnabledAt(home), print to stderr plugin ember@ember is still enabled and keeps reporting; run: claude plugin disable ember@ember. Document this in README/RUNBOOK. Ideally surface it in Settings › Agents. A sturdier option is a kill switch the hook checks, for example a marker that deconfigure writes and configure clears, read in runHook before loadConfig.
3. [Low] claude plugin disable ember@ember silently drops all hooks
After configure has stripped the settings hooks, disabling (not uninstalling) the plugin leaves no hooks registered. The docs only say to re-run configure after uninstall. Ember.app's Repair keys off launchd state, so it won't catch this. doctor does show NONE.
Fix: say "uninstall or disable" in README/RUNBOOK and the doctor NONE hint, for example run ember-claude-producer configure to re-register settings.json hooks.
Nit
- The plugin's SessionEnd
timeout: 5does nothing. Per the hooks docs, "Timeouts set on plugin-provided hooks don't raise the budget" (SessionEnd has a shared 1.5 s budget). That's harmless at the 500 ms HTTP default, but the README's "5 s timeout" doesn't apply to SessionEnd, and anEMBER_HOOK_TIMEOUT_MSabove about 1500 gets cut there (above 5000 on the other blocking hooks).
Claude Code sends the SessionEnd cause as "reason", but the producer decoded "end_reason", so the reason was always empty and SessionEnd never deleted. After /clear the claude pid stays alive, so the heartbeat re-posted the dead session for up to the 6 h marker TTL: a ghost session on the clock. The tests marshalled hookInput and round-tripped the wrong tag, so they now send Claude's raw JSON. The matcher drops bypass_permissions_disabled (no longer sent since v2.1.234) and adds resume: interactive /resume ends the old session while the pid lives on, the same ghost case as /clear.
deconfigure and uninstall (and Ember.app's Agents toggle-off, which runs deconfigure) can only strip settings-file hooks. With the ember plugin enabled, its hooks kept firing, found the binary and producer.env, and kept posting and writing markers, with no daemon left after uninstall to reap them. Before the plugin, "off" meant silent. deconfigure/uninstall now write a kill-switch file that the hook checks before it loads config, so a hook does nothing at all. configure (and so install and the app's toggle-on) removes the file. Both commands also print a reminder on stderr to disable the plugin, which is what actually unregisters its hooks.
Disabling (not uninstalling) the plugin after configure has stripped the settings-file hooks leaves no hooks at all. Ember.app's Repair goes by launchd state, so it doesn't notice. doctor's NONE line now says to run configure, and doctor reports the deconfigure kill switch so a silent plugin can be explained.
The docs told people to re-run configure only after uninstalling the plugin, but disabling it also leaves no hooks. They also didn't say that turning Ember off now silences the plugin through a kill-switch file. And "5 s timeout" was wrong for SessionEnd: it shares Claude Code's 1.5 s budget, which a plugin can't raise, so a large EMBER_HOOK_TIMEOUT_MS gets cut off there first.
|
Review addressed (4 commits on top):
Each commit passed gofmt, |
|
Re-review of 3fc93e8: fixes verified. reason tag, resume, the kill switch (checked in runHook, written by deconfigure/uninstall, removed by configure), the plugin-still-enabled note and the doctor NONE/DISABLED hints all match the review. I mutated each fix (the json tag, the resume case, the runHook kill-switch check, disableHooks in deconfigure) and each mutation fails a test. Mergeable/CLEAN; gofmt/vet/ |
Closes #209
Summary
.claude-plugin/marketplace.json, nameember) listing one plugin,ember(producers/claude-code/plugin/). Install id:ember@ember.hooks/hooks.jsonregisters the installer's 11 hooks with the same events, matchers andasyncflags. Both come from one Go list,producerHookSpecsininstall.go, andplugin_test.gofails if they drift./bin/sh ${CLAUDE_PLUGIN_ROOT}/scripts/ember-hook <event>. The shim looks for the binary in this order:$EMBER_CLAUDE_PRODUCER(used alone when set),PATH, the Go bin dir, then the Ember.app bundle. It prints nothing and always exits 0, including when no binary is found. Blocking hooks havetimeout: 5(the producer's own HTTP timeout is 500 ms). PostToolUse, PostToolUseFailure and PermissionDenied stayasync, as in the installer.producer.envstays the single source of truth, and the plugin has nouserConfig. The daemon, statusline and Ember.app read onlyproducer.env, so values entered asuserConfigwould reach the hooks and nothing else.enabledPlugins["ember@ember"]is true in the user settings,configure/install(and therefore Ember.app's Agents toggle) skip the producer hooks and strip any already there. They still writestatusLine, because a plugin can't set it.doctorprints aclaude hooks:line and warnsregistered TWICEwhen both are present.version, so installs follow the repo's latest commit.Validation
claude plugin validate .andclaude plugin validate producers/claude-code/plugin(Claude Code 2.1.288):✔ Validation passed with warnings. The only warning is "no version" (intentional, see above), so--strictfails on it.claude -p ... --setting-sources project --plugin-dir producers/claude-code/pluginwithEMBER_CLAUDE_PRODUCERpointing at a recorder script. It receivedsession-start,user-prompt-submit,pre-tool-use,post-tool-use,stop,session-endwith the hook JSON on stdin.gofmt,go vet ./...,go test ./... -raceall pass.Install / migration
Restart
claudeafterwards. To update:claude plugin marketplace update ember && claude plugin update ember@ember, or turn on auto-update in/plugin. To uninstall:claude plugin uninstall ember@ember, then runconfigureif you want the settings-file hooks back.Notes / follow-ups
enabledPluginsis checked, so a project-scoped enable can still double-register. The doctor line says so.reason, buthook.godecodesend_reason. If the docs are right, SessionEnd never DELETEs and cleanup falls to the heartbeat reap. This predates the PR and needs checking separately. The docs also saybypass_permissions_disabledis no longer sent; it's kept in the matcher for parity.