feat(plugins): hot-apply plugin changes to the current session on /plugins reload#218
feat(plugins): hot-apply plugin changes to the current session on /plugins reload#218wbxl2000 wants to merge 2 commits into
Conversation
…ugins reload `/plugins reload` now hot-loads newly installed or enabled plugin skills and connects newly enabled MCP servers into the running session — no /new required. - agent-core: add PluginManager.runtimeSnapshot() and Session.applyPluginRuntimeSnapshot(); re-render the main agent's system prompt and refresh builtin tools so new skills (and the Skill tool) appear; add McpConnectionManager.connect() for incremental, non-disruptive connects; make reloadPlugins session-scoped and keep apply failures from poisoning the plugin-load error state; report only servers that actually connected. - node-sdk: reloadPlugins() returns the applied result (PluginReloadResult). - kimi-code: /plugins reload reports what was applied and refreshes skill slash-commands; call-to-action points to a highlighted /plugins reload instead of /new. - Disable/remove/update/sessionStart are not torn down live; reload flags this via needsNewSession. - Tests, docs (en+zh), and a changeset.
🦋 Changeset detectedLatest commit: 717018b The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bca3024eab
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const stalePluginSkills = [...this.loadedPluginSkillIds].some( | ||
| (id) => !desiredSkillPlugins.has(id), | ||
| ); |
There was a problem hiding this comment.
Flag removed plugin skills for a new session
When an installed plugin is updated in place and keeps the same plugin id but removes or renames one of its skills, the old skill stays in SkillRegistry because loadRoots only adds/overwrites discovered skills and has no unload path. This check only compares plugin ids, so the updated plugin id is still considered desired and needsNewSession stays false; /plugins reload then refreshes slash commands/system prompt while the stale skill remains available without telling the user to start a new session.
Useful? React with 👍 / 👎.
| const existingServers = new Set(this.mcp.list().map((entry) => entry.name)); | ||
| const newServers = Object.fromEntries( | ||
| Object.entries(snapshot.mcpServers).filter(([name]) => !existingServers.has(name)), | ||
| ); |
There was a problem hiding this comment.
Flag existing plugin MCP config changes as unapplied
If a plugin is updated but keeps the same MCP server name while changing its command, env, cwd, or URL, reload never applies that new config because any existing runtime name is filtered out here. needsNewSession later compares only server names, so it also reports no drift; the user is told the reload is active while the session is still using the old MCP entry/config until a new session is started.
Useful? React with 👍 / 👎.
| const stalePluginServer = this.mcp | ||
| .list() | ||
| .some((entry) => entry.name.startsWith('plugin-') && !desiredServers.has(entry.name)); |
There was a problem hiding this comment.
Don't classify user MCP servers by name prefix
User and project mcp.json server names are arbitrary (the loader accepts any string key), so a non-plugin server named like plugin-local or plugin-foo:bar is now treated as stale plugin runtime whenever /plugins reload runs and the plugin snapshot does not contain that name. In those sessions, reload incorrectly reports needsNewSession even though no plugin capability is stale; track plugin-origin server names instead of using the public server-name prefix.
Useful? React with 👍 / 👎.
| const line = `Reload: +${summary.added.length} -${summary.removed.length}` + | ||
| (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); | ||
| const session = host.requireSession(); | ||
| const summary = await session.reloadPlugins(); |
There was a problem hiding this comment.
Keep hot reload out of active turns
/plugins is registered as available while streaming, and slash commands bypass the normal message queue, so running /plugins reload during an active response now mutates the live session immediately by loading skills, rerendering the main system prompt, and connecting MCP servers. The commit describes reload as a safe apply point between turns, but without an idle guard here the next tool-loop/model call in the same turn can see a different prompt/tool set than the one the turn started with; block the reload subcommand while streamingPhase/compaction is active or queue it until idle.
Useful? React with 👍 / 👎.
| if (desiredStarts.length !== activeStarts.size) return true; | ||
| return desiredStarts.some((key) => !activeStarts.has(key)); |
There was a problem hiding this comment.
Detect unchanged-key sessionStart updates
When a plugin that already contributed a sessionStart is updated but keeps the same plugin id and skill name, this key-only comparison reports no drift even if the startup skill's content or plugin instructions changed. Since sessionStart blocks are injected only once and the old block is already in the conversation context, /plugins reload cannot apply that update, so the user needs /new but is not told so.
Useful? React with 👍 / 👎.
…n-place plugin updates Two review fixes on top of the /plugins reload hot-apply: 1. Don't rewrite the main agent's system prompt on reload. Rewriting it busts the prompt-cache prefix for the whole conversation, breaks the "system prompt set once" invariant, and silently refreshed cwd/AGENTS.md too. Instead add a SkillRefreshInjector that appends the current skill listing as a system reminder when it drifts from the prompt baseline; the listing's "DISREGARD any earlier skill listings" header supersedes the stale one, and the base injector re-injects after compaction. Resume seeds the baseline so a post-resume reload still surfaces new skills (native resume skips useProfile). 2. needsNewSession now detects capabilities CHANGED IN PLACE by a plugin update, not just added/removed. Track a config digest per connected plugin MCP server and compare loaded plugin skills (additive registry) against the plugins' current declaration: a same-named MCP server whose command/args/env/cwd changed, or a removed/renamed skill, now flags needsNewSession. New skills / servers remain additive (needsNewSession stays false). Removes Agent.rerenderSystemPrompt. Adds tests for the injector and for the in-place-update and resume-baseline cases.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 717018bb6b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| export * from './subagent-host'; | ||
|
|
||
| function pluginSkillKey(pluginId: string, skillName: string): string { | ||
| return `${pluginId} ${skillName}`; |
There was a problem hiding this comment.
Replace the literal NUL in the source
This template literal contains an actual NUL byte in the .ts file rather than the escaped sequence \0. For any workflow that scans this file as text, this makes the file look binary — for example, rg pluginSkillKey packages/agent-core/src/session/index.ts reports a binary match instead of showing lines — so code search, review tooling, and text-processing scripts can skip or mishandle this module. Use an escaped separator (\0) or another printable delimiter so the runtime key stays distinct without embedding a binary byte in source.
Useful? React with 👍 / 👎.
| const skillListing = this.skills.getModelSkillListing(); | ||
| for (const agent of this.agents.values()) { | ||
| agent.systemPromptSkillListing ??= skillListing; | ||
| } |
There was a problem hiding this comment.
Don't seed resumed prompts with the live skill set
When a saved session is resumed after plugins changed while it was closed, the replayed system prompt still contains the old skill listing, but loadSkills() has already loaded the current plugin roots before this assignment. Seeding systemPromptSkillListing from the current registry makes SkillRefreshInjector believe the model has already seen those newly installed skills (or no longer sees removed ones), so even running /plugins reload after resume will not inject the corrected listing and the model keeps operating from the stale prompt.
Useful? React with 👍 / 👎.
| mergeAllAvailableSkills: this.options.skills?.mergeAllAvailableSkills, | ||
| builtinDir: this.options.skills?.builtinDir, | ||
| }); | ||
| await this.skills.loadRoots(roots); |
There was a problem hiding this comment.
Avoid hot-swapping existing plugin skill bodies
When the same plugin id is reinstalled with the same skill name but changed SKILL.md content or skillInstructions, this reload path re-scans all plugin roots and SkillRegistry.loadRoots() overwrites the existing byName entry, so the running session silently starts invoking the updated skill while needsNewSession remains false because the skill key set did not change. That violates the additive-only contract for plugin updates and can change tool behavior mid-conversation without telling the user to start a new session.
Useful? React with 👍 / 👎.
Related Issue
No prior issue — the problem is described below.
Problem
Installing or enabling a plugin (or toggling one of its MCP servers) only changed on-disk state; the running session never picked up the new capabilities. To use a freshly installed plugin's skills or MCP tools, users had to start a new session with
/new— losing their current conversation and context. (This is the same gap Claude Code closes with/reload-plugins.)What changed
/plugins reloadnow hot-applies plugin changes to the current session — no/newrequired.PluginManager.runtimeSnapshot()bundles the enabled plugins' skill roots, MCP servers, and sessionStarts into one immutable snapshot.Session.applyPluginRuntimeSnapshot()applies the additive parts to the live session: loads newly added skills, refreshes builtin tools so theSkilltool appears once skills exist, and connects only newly enabled MCP servers (existing ones untouched; their tools register via the existing status mechanism and are available on the next turn).SkillRefreshInjectorappends the current skill listing as a system reminder when it drifts from the prompt baseline (the listing's "DISREGARD any earlier skill listings" header supersedes the stale one). This keeps the prompt-cache prefix stable and preserves the "system prompt set once" invariant; it re-injects after compaction, and resume seeds the baseline so a post-resume reload still surfaces skills.reloadPluginsis now session-scoped and returns what was applied (PluginReloadResultwithaddedSkills/addedMcpServers/needsNewSession). Backward-compatible (a superset of the oldReloadSummary)./plugins reloadreports a summary and refreshes skill slash-commands; the call-to-action copy points to a highlighted/plugins reloadinstead of/new.needsNewSessiondetects this — including a same-named MCP server whose config changed, or a skill removed/renamed by a plugin update — so the TUI tells the user when/newis still required.Design choice: an explicit
/plugins reload(like Claude Code's/reload-plugins) rather than auto-applying on install — predictable behavior, a single safe apply point between turns, and it avoids applying a Core-level shared-manager mutation to a specific session implicitly.Docs (en + zh:
customization/plugins.md,customization/mcp.md,reference/slash-commands.md) are updated to correct the previous "plugin changes apply to new sessions only" guidance.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.