fix(plugin): hot-reload plugins on harness reload when the plugin set changes
What
Extends the SIGHUP harness reload to plugins, closing the last hot-reload gap after #9 covered tools. With this, a plugins/ change on a live box is adopted by an in-place reload — no opencode respawn, no dropped sessions.
Two pieces:
-
Plugin.reload()in the harness reload chain. Plugin state was the one harness cache the reload never touched — plugins loaded once per process, so a converge that shipped new plugin code neededrestart-opencode.reload()fingerprints the freshly re-read config's plugin origins and invalidates the state only on a mismatch, which runs the old hooks'disposefinalizers and re-imports on next access. -
Realpath-before-import in
PluginLoader.load. The same bug #9 fixed for tools: the Replo upgrader swaps the plugins dir behind a versioned symlink, and the module loader's cached symlink resolution would import new files from the old, deleted target (or serve the stale cached module). A freshrealpathboth defeats the stale resolution and gives updated code a new module-cache key. Same review posture as #9: no fallback on resolution failure — the unresolved path is the bug.
Why fingerprint instead of always invalidating (the tools approach)
Tools are stateless definitions; re-importing them every reload is free. Plugin hooks are stateful — the braintrust/raindrop tracing plugins hold live per-session span state — and harness reloads fire on every env rotation (~4×/day per active box), not just on plugin changes. Blindly disposing and re-instantiating hooks on each rotation would break traces mid-session for zero benefit.
The fingerprint is the origin specs plus, for path plugins, a fresh realpath of the target. Because the harness ships plugins/ as a content-addressed versioned dir behind a symlink, the realpath is effectively a content hash: it changes exactly when plugin code changes and is stable across rotations. Fixed-path npm globals (braintrust, raindrop) never change realpath, so their in-place version bumps still adopt on the next natural respawn — same as today, and the same posture as the upgrader's npmInstall row.
Known limits (unchanged from today)
- A plugin file edited in place (same realpath) is invisible to the fingerprint and to Bun's module cache — dev workflows that don't flip a versioned dir still need a restart. Production always flips.
- Structural MCP config changes still need a restart (documented in
harness-reload.ts).
Testing
- New
test/plugin/reload.test.ts: versioned-dir flip → reload serves v2 hooks and runs v1'sdispose(verified the test fails without the loader realpath fix); no-change reload keeps hook identity (no re-instantiation); reload before first plugin load is a no-op. harness-reload.test.tsnow assertsplugin.reloadruns once per instance, alongside the MCP header refresh.bun testovertest/plugin/,test/tool/registry.test.ts,test/server/,test/config/,test/cli/serve/serve-sighup.test.ts: all green (one pre-existing PTY-test timeout flake under full parallel load, passes in isolation on bothdevand this branch).tsgo --noEmitclean.
Follow-up (andytown, not this PR)
Once the live-process fleet is on a release carrying this, move plugins (with tools, per REPL-29766) from RESTART_HARNESS_DIRS to RELOAD_HARNESS_DIRS in sandbox-upgrader/entries.ts. Until then the restart-on-converge stays, which is also what forces adoption of this binary.
🤖 Generated with Claude Code
Summary by cubic
Hot-reloads plugins on SIGHUP when the resolved plugin set changes, adopting new code without restarting and keeping live sessions intact. The reload is order- and options-aware and only runs when fingerprints differ; replaced hooks are disposed.
-
New Features
- Added
Plugin.reload()that fingerprints plugin origins (spec + options + fresh realpath for path specs), is order-sensitive, and invalidates only on change. Runs during harness reload after config invalidation, before tool registry invalidation, and before MCP header refresh.
- Added
-
Bug Fixes
PluginLoader.loadnow imports through a fresh realpath (includingfile://handling) to defeat stale symlink resolution and ensure new module-cache keys when a versionedplugins/dir flips.- Closed a reload race by running
plugin.reload()beforetools.invalidate(), preventing a concurrent request from caching a tool registry built against the old plugin set.