feat(plugin-kit): let a hook handler reach its plugin's runtime - #262
Merged
Conversation
A handler was called with the value and the hook's context and nothing else, so a plugin could filter a view or observe an event in memory but could not record, enqueue or notify. Reacting to board activity — the most obvious thing a plugin author expects to do — was not possible: plugins/dues works around it by declaring no hooks at all. Handlers now take a third argument, a function resolving this plugin's runtime context: the same settings, logger, data, grants, users and notify a task or route is handed, with the same limits. It is a function because hooks are the hot path and a handler that never calls it costs nothing, and because acquiring it can fail on a fixture-mode board, where it rejects rather than pretending. Within one call it resolves once. Additive: a two-argument handler still compiles and runs, so this is a minor rather than a break. Callers driving a handler directly in a test pass unavailableHookRuntime(reason). Extracting the app's runtime builder into plugin-runtime.ts keeps the host off the page-rendering graph, and deferring the notification service's import breaks the notifications -> plugin-view -> plugin-host cycle that reaching for it would otherwise create. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf
This was referenced Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of MEI-115. This is a prerequisite discovered while designing the first-cohort webhooks plugin, so it lands before the plugins that need it.
The gap
A hook handler is called with the value and the hook's own context — nothing else. It has no
data, nonotify, nologger. So a plugin can filter a view or observe an event in memory, but it cannot record a row, queue a delivery, or raise a notification: it cannot react to the board durably at all.That is not a corner case. It is the most obvious thing a plugin author expects to be able to do, and the evidence it bites is already in the tree —
plugins/duesdeclares no hooks whatsoever and works entirely through routes, tasks and pages, andplugins/referencerecords hook calls into an in-memory array. The webhooks, RSS, analytics and calendar plugins on MEI-115's cohort list all need it.The change
A third argument, a function that resolves this plugin's runtime context:
Why a function rather than the context itself. Hooks are the hot path —
view.*filters run on every page andpostbit.badgesonce per post — and a handler that never calls it costs nothing, so the pure view filters that dominate pay for none of it. It is also honest about failure: on a fixture-mode board there is no database andawait runtime()rejects with a message saying so, rather than handing back something that pretends. Within a single handler call it resolves once and is reused.The reach is the same one everything else gets, and no larger.
datastill refuses anything outsideplugin_<key>_*,grantsstill refuses a group the operator has not opened, and a throw is still contained, counted, and subject to auto-disable.Additive, so a minor and not a break. A two-argument handler still compiles and runs — verified: the only compile error across the whole tree was one call site in the example's test, not a single handler definition. Tests driving a handler directly pass
unavailableHookRuntime(reason), whose every capability refuses with that reason.Two structural fixes this required
Wiring the app's provider into the host exposed a real import cycle:
plugin-host→ the runtime builder →notifications→plugin-view→plugin-host. It surfaced asplugin-admin.test.tsfailing to mock@meith/db.plugin-pages.tsinto its ownplugin-runtime.ts, so the host does not pull in the page-rendering graph.notifyFordefers its./notificationsimport to the moment a plugin actually sends, which breaks the cycle and keeps the notification service off the hook path until used.Validated
pnpm verifyfully green: 7948 tests, 452 files.plugins/reference— the ratchet — now exercises it onpost.created.docs/customization/plugins.md; the scaffold templates regenerated from the updated example.🤖 Generated with Claude Code
https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf
Generated by Claude Code