Skip to content

feat(claude): ship producer hooks as a Claude Code plugin - #214

Merged
tarakanof merged 6 commits into
mainfrom
feat/209-claude-plugin
Oct 3, 2026
Merged

tarakanof merged 6 commits into
mainfrom
feat/209-claude-plugin

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Closes #209

Summary

  • Repo is now a Claude Code marketplace (.claude-plugin/marketplace.json, name ember) listing one plugin, ember (producers/claude-code/plugin/). Install id: ember@ember.
  • hooks/hooks.json registers the installer's 11 hooks with the same events, matchers and async flags. Both come from one Go list, producerHookSpecs in install.go, and plugin_test.go fails if they drift.
  • Handlers use exec form: /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 have timeout: 5 (the producer's own HTTP timeout is 500 ms). PostToolUse, PostToolUseFailure and PermissionDenied stay async, as in the installer.
  • Config: producer.env stays the single source of truth, and the plugin has no userConfig. The daemon, statusline and Ember.app read only producer.env, so values entered as userConfig would reach the hooks and nothing else.
  • Double registration: Claude Code doesn't dedupe a plugin hook against a settings-file hook, so registering both would POST every event twice. When 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 write statusLine, because a plugin can't set it. doctor prints a claude hooks: line and warns registered TWICE when both are present.
  • The plugin has no version, so installs follow the repo's latest commit.

Validation

  • claude plugin validate . and claude 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 --strict fails on it.
  • End to end without installing: ran claude -p ... --setting-sources project --plugin-dir producers/claude-code/plugin with EMBER_CLAUDE_PRODUCER pointing at a recorder script. It received session-start, user-prompt-submit, pre-tool-use, post-tool-use, stop, session-end with the hook JSON on stdin.
  • New tests: hooks.json ↔ installer parity; marketplace/manifest names and source path; shim behaviour (missing binary → silent exit 0, explicit missing override → no fallback, each lookup tier forwards the event and stdin, producer failure → still exit 0, empty stdout); configure with the plugin enabled strips our hooks but keeps the user's own hooks and the statusLine; plugin disabled → hooks registered; doctor report for none, settings only, plugin only and both.
  • gofmt, go vet ./..., go test ./... -race all pass.

Install / migration

claude plugin marketplace add tarakanof/Ember
claude plugin install ember@ember
ember-claude-producer configure   # or install; with the plugin enabled this drops the settings-file hooks and keeps the statusLine
ember-claude-producer doctor      # expect "claude hooks: plugin ember@ember ..."

Restart claude afterwards. 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 run configure if you want the settings-file hooks back.

Notes / follow-ups

  • Only user-scope enabledPlugins is checked, so a project-scoped enable can still double-register. The doctor line says so.
  • The hooks docs list SessionEnd's field as reason, but hook.go decodes end_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 say bypass_permissions_disabled is no longer sent; it's kept in the matcher for parity.

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 tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: 5 does 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 an EMBER_HOOK_TIMEOUT_MS above 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.
@tarakanof

Copy link
Copy Markdown
Owner Author

Review addressed (4 commits on top):

  1. High 9f9c606 fix(claude): hookInput.EndReason is now tagged json:"reason", so SessionEnd deletes again. The SessionEnd tests send Claude's raw JSON ("reason":"…") for prompt_input_exit/clear/resume/logout/other, plus a test that an unknown reason keeps the marker. They failed before the fix. bypass_permissions_disabled is gone from the specs, hooks.json and the switch. resume is added (the old session ends while the pid lives on, the same ghost case as /clear). Matcher is now logout|prompt_input_exit|other|clear|resume.
  2. Medium d2d7b47 fix(claude): deconfigure/uninstall (and so Ember.app's toggle-off) write a kill switch at ~/.config/ember/claude-hooks.disabled. runHook checks it before loadConfig and exits 0. configure (and so install and the app's toggle-on) removes it. A subprocess test builds the binary and checks a disabled hook sends 0 requests and writes no marker, and that it sends 1 request once re-enabled. Both commands print note: plugin ember@ember is still enabled … run: claude plugin disable ember@ember on stderr when the plugin is enabled. Swift is unchanged: the app only looks at exit codes.
  3. Low b8a0d72 fix(claude): doctor's NONE line now says "If you uninstalled or disabled the plugin, run ember-claude-producer configure…". Doctor also reports DISABLED while the kill switch is present. Docs 3fc93e8 say "uninstall or disable".
  4. Nit 3fc93e8 docs: README and RUNBOOK say SessionEnd runs inside Claude Code's shared 1.5 s budget, which a plugin timeout can't raise. They note that an EMBER_HOOK_TIMEOUT_MS above ~1500 ms can cut off SessionEnd and one above 5000 ms can cut off the blocking hooks.

Each commit passed gofmt, go vet ./... and go test ./... -race. claude plugin validate . still passes (only the intentional no-version warning).

@tarakanof

Copy link
Copy Markdown
Owner Author

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/go test ./... -race green. A trial merge with #213/#212/#215 has no conflicts.

@tarakanof
tarakanof merged commit 6689d0e into main Oct 3, 2026
3 checks passed
@tarakanof
tarakanof deleted the feat/209-claude-plugin branch October 3, 2026 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(claude): ship producer hooks as a Claude Code plugin

1 participant