refactor(sdk): make plugin hook order explicit, and five composition fixes - #25
Merged
Conversation
matej21
force-pushed
the
fix/plugin-composition
branch
3 times, most recently
from
August 28, 2026 16:46
31dc2e5 to
c931ff9
Compare
defineAgent/createOrchestrator stashed the child object refs in a module-scoped WeakMap keyed by the resolved config, then flattened them to `agents: string[]`. Spreading or cloning a definition produced a new object identity, so the WeakMap lookup missed and createPreset collected no children — and validatePreset never noticed, because the flattened name list stayed internally consistent on its own. The refs now live on the value under a symbol key, which object spread copies and JSON.stringify ignores. collectFromTree walks that property instead of the WeakMap, so a spread or a clone carries the graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
sessions.getEvents read up to `limit ?? 1000` events but returned a page of `limit ?? 100`, and reported the end of the *read* window as `lastIndex`. A caller that omitted `limit` got 100 events and a cursor 900 events further on, so the next `since` poll silently skipped the gap. `lastIndex` now describes the events actually returned: the absolute index of the last event on the page when the page was truncated, the end of the read window otherwise — so a type/agentId filter still advances past the events it dropped rather than re-reading them forever. The read window stays wider than the page on purpose. Filtering happens after the read, so a window only as wide as the page starves a filtered query: `roj debug events <id> --type X` passes no limit, and on a 123-event session with 24 inference_completed a page-wide window returns 19 of them. The window is also floored at `offset + limit`, which unfiltered offset pagination past the first page always needed; under a filter `offset` counts matches rather than raw events, so no fixed window can guarantee that page, and that remains as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
The turn number lived only in an Agent field, seeded at construction from `conversationHistory.filter(m => m.role === 'assistant').length`. That derivation holds only while the history is intact: a compaction replaces the older turns with a summary, so the next reload restarted the count part-way and every beforeInference/afterInference hook saw a number that had silently gone backwards. AgentState now carries `turnNumber`, incremented by the reducer on inference_completed, and the Agent reads it instead of keeping its own counter. Old event logs replay through the same reducer, so nothing needs a migration. This changes the hook-context contract, deliberately. The old field was incremented once per *attempted* turn, before beforeInference ran; the new one counts *committed* turns, so an attempt that commits nothing no longer consumes a number. A paused attempt therefore reads 1, 1, 2 where it used to read 1, 2, 3 (a skip is unaffected — it commits an inference_completed of its own). Per-commit is the semantics worth keeping: a paused or failed attempt runs again on the same pending messages, so it is the same turn, and it is the only definition that can be persisted at all — nothing is emitted at the moment an attempt starts. A test pins the new behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
The reconnecting → connected transition re-fetched services and sessionState but not the chat, so every message, agent reply and question the server pushed while the socket was down stayed missing until a full loadSession. A missed question was worse than a missed message: it left `pendingQuestions` empty, so the answer UI never appeared and the input stayed unblocked for a question the agent was still waiting on. The transition now re-fetches user-chat.getMessages and derives the open questions from it. A send that is still in flight is not on the server yet, so it is carried over rather than dropped by the overwrite — `pendingMessages` tracks exactly those. Both refetches take the generation guard loadSession already uses on its own writes: opening another session while one is in flight would otherwise land session A's messages in session B. The `sessionState.get` inside loadSession was missing the same guard and gets it too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
Reacting to a built-in event meant importing an internal path such as `~/plugins/mailbox/state.js`: `.events([...])` takes the factory objects, and only `agentEvents` was public. The `BuiltinEvent` union was already exported, but nothing behind it was reachable. The six remaining factories that union is built from — session, tool, llm, context, mailbox, user-chat — are now public, plus `getAgentUnconsumedMailbox` to go with the mailbox readers already exported. Deliberately no further plugin event factories: emitting another plugin's events is not something a third party should be doing, and every public name is a commitment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
Hook order was whatever order the plugin array happened to be in, and
most agent hooks stop at the first non-null result — so a literal array
decided who wins. That array now exists three times: `fullPlugins`,
`isolatePlugins`, and `defaultSystemPlugins` in the test harness. And
`.dependencies()` looked like it might govern order while it only
exposes another plugin's methods.
A plugin now declares `.order(n)` and SessionManager sorts the session's
plugins by it, so no array decides anything. The sort is stable and
everything undeclared defaults to 1000, so preset-level and third-party
plugins keep running after the built-ins in registration order. The
built-ins declare 10…150 matching their current positions: today's
effective order is unchanged, and tests pin the ladder to `fullPlugins`
and assert every profile is sorted.
That last part is what host-selected profiles need. A profile is a
subset, so hand-maintained arrays would let two hosts run the same
plugins in different sequences; a subset of a sorted ladder is sorted,
whatever a host drops.
Ordering is declared per plugin rather than derived from
`.dependencies()`: a data dependency ("I call your methods") is not the
same claim as hook precedence ("I decide before you do"), and a
topological sort over the former would have reordered plugins that never
asked to be reordered.
The test harness now uses `fullPlugins` instead of keeping its own copy,
leaving the profiles in bootstrap beside the RPC contract they define.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
…he default The order() JSDoc pointed at `plugins/builtin.ts`, which does not exist — the ladder lives in bootstrap.ts and in each plugin's own call — and both new comment blocks ran over the repo's ceiling. A third party told that no order means DEFAULT_PLUGIN_ORDER could not import the number; now it can. The turn-numbering test carried the migration rationale inline; it is already in the commit that made the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
matej21
force-pushed
the
fix/plugin-composition
branch
from
August 31, 2026 12:40
4cc327f to
44d4780
Compare
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.
Six independent fixes around plugin composition, each its own commit.
Plugin hook order was incidental.
SessionManager.buildPluginsiterates plugins in plain array order, and most agent hooks stop at the first non-null result — so a literal array inbootstrap.tssilently decides who wins..dependencies()looks like it might govern order, but it only exposes another plugin's methods.Host-selected plugin profiles make that sharper. There are now three hand-maintained arrays —
fullPlugins,isolatePlugins(a subset for hosts with no OS process table) and the test harness's own list — and a subset is exactly where two hosts start running the same plugins in a different relative sequence, silently.A plugin now declares
.order(n)and the session's plugins are sorted by it, so the array decides nothing. The sort is stable and everything undeclared defaults to 1000, which keeps preset-level and third-party plugins running after the built-ins in registration order. The built-ins declare 10…150 matching their current positions: today's effective order is unchanged for both profiles, and a test pins the ladder to the list so the two cannot drift apart. Ordering is declared per plugin rather than derived from.dependencies()— a data dependency is not the same claim as hook precedence, and a topological sort over the former would reorder plugins that never asked for it. A subset of a sorted ladder is sorted whatever a host drops, so the property survives profile selection by construction; a test asserts every profile's array is in ascending declared order, making the sort provably a no-op for both. The harness's duplicate list is gone — it importsfullPluginsinstead. The profiles stay inbootstrap.ts, where they are tied to the RPC contract (BuiltinMethodSchemasderives fromfullPlugins); splitting that pairing to consolidate elsewhere would be the worse trade.The audit behind this change is in the commit message. Two findings worth surfacing here:
limits-guard's pause againstcontext-compact's compaction — is not decided by the built-in array. Neither plugin is in it; both are preset-only, so their order has always come from the preset author's ownplugins: [...]literal.<user>tags (inuserCommunication: xml | both),user-chat'safterInferencereturnsmodifyand the loop stops, solimits-guard's no-progress and repetition counters silently skip that turn.Neither was reordered here — both need a deliberate behaviour decision and are left for a follow-up. The mechanism to express that decision now exists.
Also in this PR:
sessions.getEventsread up tolimit ?? 1000events, returnedlimit ?? 100, and reported the end of the read window aslastIndex— so a caller who omittedlimitskipped 900 events on the next poll. The cursor now describes the page actually returned. The read window stays wider than the page (max(limit ?? 1000, offset + limit)) so atype/agentIdfilter still scans deeply enough to fill one; narrowing it to the page would silently halve what a filtered read finds. Under a filter,offsetcounts matches while the window counts raw events, so no fixed window can guarantee a deep filtered page — that case is unchanged, not fixed.AgentStateand maintained by the reducer; old logs replay through the same reducer, so no migration. This changes the count for an attempt that commits nothing — a pause, or an inference failure — from1,2,3to1,1,2: such an attempt re-runs on the same pending messages, so it is the same turn. A skipped inference still commits and still consumes a number, as before. Per-commit is also the only definition that can be persisted, since nothing is emitted when an attempt begins.defineAgentkept the sub-agent graph in a module-levelWeakMapkeyed by object identity, so spreading or cloning a definition silently dropped its children. The refs now ride on the value under a symbol key.BuiltinEventunion are exported, so reacting to a built-in event no longer needs a deepsrc/import.pendingMessages, not yet on the server) is carried over rather than dropped.🤖 Generated with Claude Code
https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM