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 4 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: b53aef0 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 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0fc42449a
ℹ️ 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".
| if (registry === undefined) return undefined; | ||
| const baseline = this.agent.systemPromptSkillListing; | ||
| const current = registry.getModelSkillListing(); | ||
| if (current.length === 0) return undefined; |
There was a problem hiding this comment.
Surface empty skill listings after resume
When a saved session is resumed after its only plugin skill was removed while the app was closed, the replayed config.update still supplies the old system prompt (checked in Session.resume, which only re-bootstrap profiles when the prompt is empty). Fresh evidence is this new early return: because the current registry listing is now empty, SkillRefreshInjector emits nothing, so the model keeps seeing the stale baked-in skill listing even though the skill/tool is no longer registered and /plugins reload has no way to correct it. The empty-listing case still needs a reminder that tells the model to disregard earlier skill listings.
Useful? React with 👍 / 👎.
| const activeStarts = new Set( | ||
| (main?.pluginSessionStarts ?? []).map((start) => `${start.pluginId}:${start.skillName}`), | ||
| ); |
There was a problem hiding this comment.
Detect replayed sessionStart drift after resume
When a saved session is resumed after a plugin's sessionStart was renamed or removed while the app was closed, the conversation still contains the old replayed plugin_session_start injection, but the resumed main.pluginSessionStarts is populated from the current plugin manager state. This comparison therefore treats the current desired keys as already active and reports needsNewSession: false, so /plugins reload does not tell the user to start a new session even though the stale startup block remains in context and cannot be retracted or replaced.
Useful? React with 👍 / 👎.
Related Issue
No prior issue. The problem is described below.
Problem
Installing or enabling a plugin, or enabling one of its MCP servers, updates plugin state on disk but the running session does not see the new capabilities until
/new. That makes additive changes more disruptive than necessary because users have to leave the current conversation to use newly available plugin skills or MCP tools.What changed
/plugins reloadnow hot-applies additive plugin runtime changes to the current session:/newto fully apply.Verification
pnpm --filter @moonshot-ai/agent-core exec vitest run test/agent/injection/skill-refresh.test.ts test/harness/plugin-reload-session.test.ts test/mcp/connection-manager.test.ts test/plugin/manager.test.ts test/rpc/plugins-rpc.test.tspnpm exec vitest run apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.tspnpm --filter @moonshot-ai/agent-core run typecheckpnpm --filter @moonshot-ai/kimi-code-sdk run typecheckpnpm --filter @moonshot-ai/kimi-code run typecheckpnpm -C docs run buildgit diff --checkChecklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.