Skip to content

feat(modules): load/initialize split and guarded module commands - #3

Merged
md5sha256 merged 3 commits into
mainfrom
feat/load-then-initialize
Oct 27, 2026
Merged

md5sha256 merged 3 commits into
mainfrom
feat/load-then-initialize

Conversation

@md5sha256

@md5sha256 md5sha256 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Load, then initialize

  • ModuleLifecycleManager.load() loads module jars and creates the modules without calling initialize. initializeLoaded() then initializes them.
  • start() is load() + initializeLoaded(), unchanged in behaviour.
  • stop() also closes loaded-but-never-initialized modules, without shutdown.

Guarded module commands

  • A module overrides PluginModule.declareCommands(ModuleCommandRoot root, ModuleCommandRegistrar registrar), a default no-op. root.then(...) adds a subcommand under the host's root, and registrar.register(...) adds a top-level command.
  • The host calls declareCommands(commands, root, unavailable) from its LifecycleEvents.COMMANDS handler, between load() and initializeLoaded(), every time the event fires.
  • CommandGuard rebuilds every tree, so executors and suggestions run module code only while the module is active. Otherwise the host's ModuleUnavailableHandler replies, and tab completion is empty.
  • Modules get no raw access to Paper's registrar, so they can't register an unguarded command. Redirects are rejected because they would bypass the guard.
  • If declareCommands throws, nothing it declared is registered and the module is unloaded.
  • Trees are cached and re-registered when Paper fires the event again on /minecraft:reload; the module isn't asked to declare a second time.
  • README, AGENTS.md and the PluginModule#initialize Javadoc now say that commands are declared, never unregistered, and need no cleanup after a failed start.

Why

Paper only accepts commands during its lifecycle commands event, before modules initialize, and its API can't remove them. Modules need their own commands, and a module that fails to start must not run with uninitialized state.

Test plan

  • ./gradlew build
    • ModuleLifecycleManagerTest: 16/16, 5 of them new, for the load/initialize split.
    • ModuleCommandDeclarationTest: 10/10, all new. Covers: runs when active; unavailable before initialize, after a failed initialize and after stop; nested executors guarded; suggestions guarded; top-level commands registered guarded; a failed declaration registers nothing and isn't initialized; redirects rejected; a later commands event replays without redeclaring.
  • Live check on a Paper server.

🤖 Generated with Claude Code

https://claude.ai/code/session_011DvdDwRoC7qHZ7yKUv1ufw

…alizeLoaded

A host can now load module jars, act on the loaded modules (for example,
let them declare commands during the server's registration window), and
initialize them later. start() keeps its behaviour as load() followed by
initializeLoaded(). stop() closes modules that were loaded but never
initialized, without calling shutdown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DvdDwRoC7qHZ7yKUv1ufw
@md5sha256 md5sha256 changed the title feat(modules): split ModuleLifecycleManager.start into load and initializeLoaded feat(modules): load/initialize split and guarded module commands Oct 27, 2026
md5sha256 and others added 2 commits October 27, 2026 11:59
Paper only accepts commands during LifecycleEvents.COMMANDS, which fires
before modules initialize, and cannot unregister them. A module that
implements CommandDeclaringModule now declares its commands through
ModuleLifecycleManager#declareCommands, between load() and
initializeLoaded(), using a ModuleCommandRoot (subcommands under the
host's root) and a ModuleCommandRegistrar (top-level commands).

CommandGuard rebuilds each tree so executors and suggestion providers
run module code only while the module is active; otherwise the host's
ModuleUnavailableHandler replies. A failed start therefore leaves no
command to clean up. Redirects are rejected because they would bypass
the guard. Declared trees are kept and replayed when the commands event
fires again on /minecraft:reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DvdDwRoC7qHZ7yKUv1ufw
Every module can declare commands by overriding PluginModule#declareCommands,
which does nothing by default. Removes the separate CommandDeclaringModule
interface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DvdDwRoC7qHZ7yKUv1ufw
@md5sha256
md5sha256 merged commit 284bc07 into main Oct 27, 2026
1 check passed
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.

1 participant