feat(plugins): hot-apply plugin changes to the current session on /plugins 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.
This commit is contained in:
qer 2026-05-29 20:06:32 +08:00
parent 1873859b0e
commit bca3024eab
25 changed files with 634 additions and 45 deletions

View file

@ -0,0 +1,9 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---
`/plugins reload` now hot-applies plugin changes to the current session — no `/new` required. Newly installed or enabled plugin skills load immediately (the main agent's skill list and `Skill` tool are refreshed) and newly enabled plugin MCP servers are connected. Disable, remove, update, and `sessionStart` changes are not torn down in a running session; reload reports when a new session is still needed to fully apply them.
Adds `PluginManager.runtimeSnapshot()` and `Session.applyPluginRuntimeSnapshot()` in `agent-core`; the SDK's `reloadPlugins()` now returns the applied result (`PluginReloadResult` / `PluginRuntimeApplyResult`).

View file

@ -35,6 +35,7 @@ import {
} from './config';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handlePluginsCommand } from './plugins';
import type { SkillListSession } from './skills';
import {
handleExportDebugZipCommand,
handleExportMdCommand,
@ -121,6 +122,7 @@ export interface SlashCommandHost {
showSessionPicker(): Promise<void>;
sendNormalUserInput(text: string): void;
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
refreshSkillCommands(session?: SkillListSession): Promise<void>;
readonly skillCommandMap: Map<string, string>;
// Controller refs

View file

@ -1,6 +1,8 @@
import { homedir as osHomedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import chalk from 'chalk';
import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import {
@ -87,7 +89,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
}
await session.setPluginMcpServerEnabled(id, server, action === 'enable');
host.showStatus(
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`,
`${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run ${reloadCommandText(host.state.theme.colors)} to apply to this session.`,
);
return;
}
@ -264,7 +266,9 @@ async function applyPluginEnabled(
? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.`
: '';
if (showStatus) {
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
host.showStatus(
`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run ${reloadCommandText(host.state.theme.colors)} to apply to this session.${mcpHint}`,
);
}
const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : '';
return `${pluginInlineChangeHint()}${inlineMcpHint}`;
@ -393,22 +397,39 @@ async function installPluginFromSource(
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
: '';
const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed';
const reloadCmd = reloadCommandText(host.state.theme.colors);
host.showStatus(
`${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
`${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run ${reloadCmd} to apply to this session.`,
);
if (options?.successNotice === 'marketplace') {
host.showNotice(
`Installed or updated ${summary.displayName}`,
`Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`,
`Marketplace install or update succeeded for ${summary.id}. Run ${reloadCmd} to apply to this session.`,
);
}
}
async function reloadPlugins(host: SlashCommandHost): Promise<void> {
const summary = await host.requireSession().reloadPlugins();
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();
const applied = summary.applied;
const parts = [`+${summary.added.length} plugins`];
if (applied !== undefined) {
parts.push(
`${applied.addedSkills.length} skills`,
`${applied.addedMcpServers.length} MCP servers`,
);
}
let line = `Reload: ${parts.join(', ')} now active`;
if (summary.errors.length > 0) {
line += ` (${summary.errors.length} errors)`;
}
if (summary.removed.length > 0 || (applied?.needsNewSession ?? false)) {
line += '. Removals, updates, and sessionStart changes need a new session to fully apply.';
}
host.showStatus(line);
// New skills may add slash commands; refresh the palette/autocomplete.
await host.refreshSkillCommands(session);
}
function resolvePluginInstallSource(source: string, workDir: string): string {
@ -420,5 +441,15 @@ function resolvePluginInstallSource(source: string, workDir: string): string {
}
function pluginInlineChangeHint(): string {
return 'pending /new';
return 'pending reload';
}
/**
* Render a `/plugins reload` reference with the theme accent + bold so the
* call to action stands out against the dimmed status line. chalk restores the
* surrounding status color after the highlighted span, so it composes with the
* status component's own coloring.
*/
function reloadCommandText(colors: { readonly accent: string }): string {
return chalk.hex(colors.accent).bold('/plugins reload');
}

View file

@ -300,7 +300,7 @@ describe('plugins selector dialogs', () => {
},
],
selectedId: 'kimi-datasource',
pluginHint: { id: 'kimi-datasource', text: 'pending /new' },
pluginHint: { id: 'kimi-datasource', text: 'pending reload' },
colors: darkColors,
onSelect: vi.fn(),
onCancel: vi.fn(),
@ -308,7 +308,7 @@ describe('plugins selector dialogs', () => {
const out = picker.render(120).map(strip).join('\n');
expect(out).toContain('? Kimi Datasource enabled pending /new');
expect(out).toContain('? Kimi Datasource enabled pending reload');
});
it('defaults plugin removal confirmation to cancel', () => {

View file

@ -1486,9 +1486,11 @@ describe('KimiTUI message flow', () => {
);
});
const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(out).toContain(' Demo disabled pending /new');
expect(out).toContain(' Demo disabled pending reload');
expect(out).not.toContain('Space enable');
expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.');
expect(stripSgr(renderTranscript(driver))).not.toContain(
'Disabled demo. Run /plugins reload to apply to this session.',
);
});
it('toggles plugin MCP servers from the overview MCP picker', async () => {
@ -1579,12 +1581,43 @@ describe('KimiTUI message flow', () => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent);
});
const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n'));
expect(out).toContain(' data disabled pending /new');
expect(out).toContain(' data disabled pending reload');
expect(stripSgr(renderTranscript(driver))).not.toContain(
'Disabled MCP server data for kimi-datasource. Run /new to apply.',
'Disabled MCP server data for kimi-datasource. Run /plugins reload to apply to this session.',
);
});
it('reports the reload summary and refreshes skill commands on /plugins reload', async () => {
const session = makeSession({
reloadPlugins: vi.fn(async () => ({
added: [],
removed: [],
errors: [],
applied: {
addedSkills: ['hot-skill'],
addedMcpServers: ['plugin-demo:data'],
needsNewSession: true,
},
})),
});
const { driver } = await makeDriver(session);
const refreshSpy = vi.spyOn(
driver as unknown as { refreshSkillCommands: (...args: unknown[]) => Promise<void> },
'refreshSkillCommands',
);
driver.handleUserInput('/plugins reload');
// The status line wraps at the terminal width, so collapse whitespace.
const transcript = () => stripSgr(renderTranscript(driver)).replaceAll(/\s+/g, ' ');
await vi.waitFor(() => {
expect(transcript()).toContain('Reload: +0 plugins, 1 skills, 1 MCP servers now active');
});
expect(transcript()).toContain('need a new session to fully apply');
expect(session.reloadPlugins).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('requires confirmation before /plugins remove removes a plugin', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);

View file

@ -17,7 +17,7 @@ Project entries override user-level entries with the same name.
The easiest entry point is running `/mcp-config` in the TUI, which guides you through adding, editing, or removing servers. To check connection status, run `/mcp`.
Plugins can also declare MCP servers in `kimi.plugin.json` or `.kimi-plugin/plugin.json`. Plugin-declared servers are enabled by default but only start in new sessions; disable or re-enable them from `/plugins` or with `/plugins mcp disable|enable <plugin-id> <server>`, then start a new session. See [Plugins](./plugins.md) for details.
Plugins can also declare MCP servers in `kimi.plugin.json` or `.kimi-plugin/plugin.json`. Plugin-declared servers are enabled by default; a newly installed or enabled one comes online in the current session after `/plugins reload` (or in a new session). Disable or re-enable them from `/plugins` or with `/plugins mcp disable|enable <plugin-id> <server>` — enabling applies on `/plugins reload`, while disabling needs a new session to fully take effect. See [Plugins](./plugins.md) for details.
The top-level shape of `mcp.json` is:

View file

@ -30,7 +30,7 @@ Most users only need the interactive manager. You can also use these slash comma
| `/plugins enable <id>` | Enable a plugin; opens the manager when `<id>` is omitted. |
| `/plugins disable <id>` | Disable a plugin; opens the manager when `<id>` is omitted. |
| `/plugins remove <id>` | Remove a plugin; requires confirmation. |
| `/plugins reload` | Reload `installed.json` and each plugin manifest. |
| `/plugins reload` | Reload `installed.json` and each plugin manifest, and hot-apply newly added skills and newly enabled MCP servers to the current session. |
| `/plugins mcp enable <id> <server>` | Enable an MCP server declared by a plugin. |
| `/plugins mcp disable <id> <server>` | Disable an MCP server declared by a plugin. |
@ -38,9 +38,9 @@ For general slash command behavior, see [Slash commands](../reference/slash-comm
Kimi Code CLI currently installs plugins per user. Records are stored under `$KIMI_CODE_HOME/plugins/` and apply across all projects. Project-local, repository-shared, admin-managed, and `--scope` installs are not supported yet.
Plugin changes apply to new sessions only. After installing, enabling, disabling, removing, or reloading a plugin, or changing an MCP server toggle, start a fresh session with `/new`. The current session is not updated; new skills, session-start behavior, and MCP servers load only in new sessions.
After installing or enabling a plugin (or enabling one of its MCP servers), run `/plugins reload` to apply the change to the current session — no `/new` required. Reload hot-loads newly added skills (the main agent's skill list and the `Skill` tool are refreshed) and connects newly enabled MCP servers; their tools become available on the next turn. Additive changes only: disabling or removing a plugin, updating one, and `sessionStart` injections are not torn down in a running session. When any of those are pending, `/plugins reload` reports that a new session (`/new`) is still required to fully apply them.
Local installs are copied into `$KIMI_CODE_HOME/plugins/managed/<id>/`, and Kimi Code CLI always runs from that managed copy. Editing the original source directory after install has no effect until you reinstall — `/plugins reload` re-reads install records and manifests, not the original source. Removing a plugin deletes only its install record; the managed copy and the original source files are left on disk.
Local installs are copied into `$KIMI_CODE_HOME/plugins/managed/<id>/`, and Kimi Code CLI always runs from that managed copy. Editing the original source directory after install has no effect until you reinstall — `/plugins reload` re-reads install records and manifests (not the original source) and applies the additive changes above. Removing a plugin deletes only its install record; the managed copy and the original source files are left on disk.
## Plugin manifest
@ -134,14 +134,16 @@ HTTP server:
For stdio servers, `command` may be a command on `PATH` or a `./` path inside the plugin root. If `cwd` is set, it must also start with `./` and stay inside the plugin root; other values are rejected and the server is omitted. Plugin MCP servers inherit the current process environment; values under `env` are literal overrides.
Plugin MCP servers start only in new sessions. To disable or re-enable one, run `/plugins`, select the plugin, and press `M`. Shortcut commands are also available:
Newly enabled plugin MCP servers come online when you run `/plugins reload` (or in a new session). To disable or re-enable one, run `/plugins`, select the plugin, and press `M`. Shortcut commands are also available:
```sh
# Disabling is not torn down live — a new session fully applies it:
/plugins mcp disable kimi-finance finance
/new
# Enabling takes effect in the current session after a reload:
/plugins mcp enable kimi-finance finance
/new
/plugins reload
```
## Security model
@ -151,5 +153,5 @@ Plugins expose a limited loading surface:
- Install and session startup read only plugin manifests and Markdown skill files.
- All paths must stay inside the plugin root after symlinks are resolved.
- Command-backed plugin tools, hooks, and legacy tool runtimes are not executed by the plugin loader.
- MCP servers declared by enabled plugins start only in new sessions and can be disabled from `/plugins`.
- MCP servers declared by enabled plugins come online via `/plugins reload` (or a new session) and can be disabled from `/plugins`.
- Bad manifests or unsafe paths produce diagnostics in `/plugins info <id>` without crashing unrelated sessions.

View file

@ -56,7 +56,7 @@ Some commands are only available in the idle state. Running them while the sessi
| `/usage` | — | Show token usage, context consumption, and quota information. | Yes |
| `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes |
| `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes |
| `/plugins` | — | Open the interactive plugin manager for user/global installs: install, inspect, enable, disable, confirm removal, reload, browse the official marketplace, and toggle plugin MCP servers. Shortcut subcommands remain available. | Yes |
| `/plugins` | — | Open the interactive plugin manager for user/global installs: install, inspect, enable, disable, confirm removal, reload (hot-applies new skills and MCP servers to the current session), browse the official marketplace, and toggle plugin MCP servers. Shortcut subcommands remain available. | Yes |
| `/version` | — | Show the Kimi Code CLI version number. | Yes |
| `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes |

View file

@ -17,7 +17,7 @@ MCP server 配置写在 `mcp.json` 中,分为两层:
最方便的入口是在 TUI 中运行 `/mcp-config`,它会引导你新增、编辑或删除 server。要查看当前连接状态可运行 `/mcp`
Plugins 也可以在 `kimi.plugin.json``.kimi-plugin/plugin.json` 中声明 MCP servers。Plugin 声明的 servers 默认启用,但只会在新会话中启动;可以在 `/plugins` 中禁用或重新启用,也可以使用 `/plugins mcp disable|enable <plugin-id> <server>`,然后开启新会话。详见 [Plugins](./plugins.md)。
Plugins 也可以在 `kimi.plugin.json``.kimi-plugin/plugin.json` 中声明 MCP servers。Plugin 声明的 servers 默认启用;新安装或新启用的 server 在运行 `/plugins reload`(或新会话)后即在当前会话上线。可以在 `/plugins` 中禁用或重新启用,也可以使用 `/plugins mcp disable|enable <plugin-id> <server>`——启用在 `/plugins reload` 后生效,禁用则需开启新会话才能完全生效。详见 [Plugins](./plugins.md)。
`mcp.json` 的顶层结构如下:

View file

@ -30,7 +30,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl
| `/plugins enable <id>` | 启用 plugin省略 `<id>` 时打开管理器。 |
| `/plugins disable <id>` | 禁用 plugin省略 `<id>` 时打开管理器。 |
| `/plugins remove <id>` | 移除 plugin需二次确认。 |
| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest。 |
| `/plugins reload` | 重载 `installed.json` 和各 plugin manifest,并把新增的 Skills 和新启用的 MCP servers 热加载到当前会话。 |
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server。 |
| `/plugins mcp disable <id> <server>` | 禁用 plugin 声明的 MCP server。 |
@ -38,9 +38,9 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl
Kimi Code CLI 目前按用户安装 plugins记录在 `$KIMI_CODE_HOME/plugins/` 下,对所有项目生效。暂不支持项目级、仓库级、管理员分发,以及带 `--scope` 的安装方式。
Plugin 变更只对新会话生效。安装、启用/禁用、移除、重载 plugin或修改 MCP server 开关后,需要通过 `/new` 开启新会话;当前会话不会更新,新的 Skills、会话启动行为和 MCP servers 只会在新会话中加载
安装或启用 plugin或启用其某个 MCP server运行 `/plugins reload` 即可把变更应用到当前会话,无需 `/new`。重载会热加载新增的 Skills刷新 main agent 的 Skill 列表和 `Skill` 工具),并连接新启用的 MCP servers其工具在下一个回合可用。仅作用于「新增」禁用或移除 plugin、更新 plugin以及 `sessionStart` 注入,不会在运行中的会话里被回收;存在这类待处理变更时,`/plugins reload` 会提示仍需开启新会话(`/new`)才能完全生效
本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed/<id>/`Kimi Code CLI 始终从这份托管副本运行。安装后再编辑原始源目录不会生效,需要重新安装——`/plugins reload`会重读安装记录和 manifest不会重读原始源。移除 plugin 只会删除其安装记录,托管副本和原始源文件仍保留在磁盘上。
本地安装会被拷贝到 `$KIMI_CODE_HOME/plugins/managed/<id>/`Kimi Code CLI 始终从这份托管副本运行。安装后再编辑原始源目录不会生效,需要重新安装——`/plugins reload`重读安装记录和 manifest不会重读原始源并应用上述「新增」变更。移除 plugin 只会删除其安装记录,托管副本和原始源文件仍保留在磁盘上。
## Plugin manifest
@ -134,14 +134,16 @@ HTTP server
对于 stdio servers`command` 可以是 `PATH` 上的命令,也可以是 plugin 根目录内以 `./` 开头的路径。如果设置了 `cwd`,它也必须以 `./` 开头并位于 plugin 根目录内;其他取值会被拒绝,该 server 会被忽略。Plugin MCP servers 会继承当前进程的环境变量;`env` 中的值会按字面量覆盖。
Plugin MCP servers 只会在新会话中启动。要禁用或重新启用某个 server运行 `/plugins`,选中 plugin 后按 `M`。也可以使用快捷命令:
新启用的 plugin MCP servers 会在运行 `/plugins reload`(或新会话)时上线。要禁用或重新启用某个 server运行 `/plugins`,选中 plugin 后按 `M`。也可以使用快捷命令:
```sh
# 禁用不会在运行中的会话里回收——需新会话才能完全生效:
/plugins mcp disable kimi-finance finance
/new
# 启用在当前会话 reload 后即生效:
/plugins mcp enable kimi-finance finance
/new
/plugins reload
```
## 安全模型
@ -151,5 +153,5 @@ Plugins 的加载范围有限:
- 安装和会话启动时,仅读取 plugin manifests 和 Markdown Skill 文件。
- 所有路径在解析符号链接后仍必须位于 plugin 根目录内。
- 命令型 plugin tools、hooks 和旧式工具运行时不会由 plugin loader 执行。
- 已启用 plugin 声明的 MCP servers 只会在新会话中启动,并且可以从 `/plugins` 中禁用。
- 已启用 plugin 声明的 MCP servers 会通过 `/plugins reload`(或新会话)上线,并且可以从 `/plugins` 中禁用。
- 损坏的 manifest 或不安全路径会显示在 `/plugins info <id>` 的 diagnostics 中,不会让无关会话崩溃。

View file

@ -56,7 +56,7 @@
| `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 |
| `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 |
| `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 |
| `/plugins` | — | 打开面向 user/global用户全局安装的交互式 plugin 管理器,用于安装、查看、启用、禁用、确认移除、重载、浏览官方 marketplace以及启用或禁用 plugin MCP servers快捷子命令仍可使用。 | 是 |
| `/plugins` | — | 打开面向 user/global用户全局安装的交互式 plugin 管理器,用于安装、查看、启用、禁用、确认移除、重载(把新增 Skills 和 MCP servers 热加载到当前会话)、浏览官方 marketplace以及启用或禁用 plugin MCP servers快捷子命令仍可使用。 | 是 |
| `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 |
| `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 |

View file

@ -266,6 +266,24 @@ export class Agent {
this.tools.setActiveTools(profile.tools);
}
/**
* Re-render only the system prompt from a profile, leaving the active tool
* set untouched. Used after plugin skills are hot-loaded so the model sees
* the new skills (the prompt's skill listing is rendered live from the
* registry). Unlike {@link useProfile}, this does not reset active tools,
* which can be mutated at runtime and must survive a reload.
*/
rerenderSystemPrompt(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void {
const systemPrompt = profile.systemPrompt({
osEnv: this.kaos.osEnv,
cwd: this.config.cwd,
skills: this.skills?.registry,
cwdListing: context?.cwdListing,
agentsMd: context?.agentsMd,
});
this.config.update({ systemPrompt });
}
async resume(): Promise<{ warning?: string }> {
const result = await this.records.replay();
await this.background.loadFromDisk();

View file

@ -167,7 +167,22 @@ export class McpConnectionManager {
return Math.max(0, endedAt - this.initialLoadStartedAt);
}
/**
* Connect additional MCP servers into an already-running manager without
* disturbing existing entries or resetting the initial-load timing metrics.
* Used to hot-add plugin servers when plugins are reloaded mid-session. Tool
* registration happens automatically via the {@link onStatusChange}
* subscription, so agents pick the new tools up on their next turn.
*/
async connect(configs: Record<string, McpServerConfig>): Promise<void> {
await Promise.allSettled(this.spawnEntries(configs));
}
private async connectAllNow(configs: Record<string, McpServerConfig>): Promise<void> {
await Promise.allSettled(this.spawnEntries(configs));
}
private spawnEntries(configs: Record<string, McpServerConfig>): Promise<unknown>[] {
const tasks: Promise<unknown>[] = [];
for (const [name, config] of Object.entries(configs)) {
const disabled = config.enabled === false;
@ -183,7 +198,7 @@ export class McpConnectionManager {
tasks.push(this.connectOne(entry, this.beginConnectAttempt(entry)));
}
}
await Promise.allSettled(tasks);
return tasks;
}
async reconnect(name: string): Promise<void> {

View file

@ -14,6 +14,7 @@ import {
type PluginInfo,
type PluginMcpServerInfo,
type PluginRecord,
type PluginRuntimeSnapshot,
type PluginSource,
type PluginSummary,
type ReloadSummary,
@ -206,6 +207,19 @@ export class PluginManager {
return out;
}
/**
* Bundle the currently-enabled plugins' skill roots, MCP servers, and
* sessionStart skills into a single immutable snapshot. Used to hot-apply
* plugin changes to a live session via `Session.applyPluginRuntimeSnapshot`.
*/
runtimeSnapshot(): PluginRuntimeSnapshot {
return {
pluginSkillRoots: this.pluginSkillRoots(),
mcpServers: this.enabledMcpServers(),
sessionStarts: this.enabledSessionStarts(),
};
}
enabledMcpServers(): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
for (const record of this.records.values()) {

View file

@ -1,4 +1,5 @@
import type { McpServerConfig } from '../config/schema';
import type { SkillRoot } from '../skill';
export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info';
@ -118,6 +119,40 @@ export interface ReloadSummary {
readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>;
}
/**
* An immutable description of what the currently-enabled plugins want the
* runtime to look like: skill roots to load, MCP servers to run, and
* sessionStart skills to auto-inject. Produced by `PluginManager` and applied
* to a live session by `Session.applyPluginRuntimeSnapshot`.
*/
export interface PluginRuntimeSnapshot {
readonly pluginSkillRoots: readonly SkillRoot[];
readonly mcpServers: Record<string, McpServerConfig>;
readonly sessionStarts: readonly EnabledPluginSessionStart[];
}
/**
* What `Session.applyPluginRuntimeSnapshot` was actually able to hot-load into
* the current session. Only additive capabilities take effect live; anything
* that would require tearing down existing state sets `needsNewSession`.
*/
export interface PluginRuntimeApplyResult {
readonly addedSkills: readonly string[];
readonly addedMcpServers: readonly string[];
/**
* True when the live session still differs from the snapshot in a way that
* only a new session can reconcile: a disabled/removed plugin MCP server is
* still connected, or the set of sessionStart injections drifted (new ones
* cannot be injected mid-conversation, old ones cannot be retracted).
*/
readonly needsNewSession: boolean;
}
/** Result of `/plugins reload`: the manager-level diff plus what was applied. */
export interface PluginReloadResult extends ReloadSummary {
readonly applied?: PluginRuntimeApplyResult;
}
export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export function normalizePluginId(name: string): string {

View file

@ -9,7 +9,7 @@ import type { SessionMeta } from '#/session';
import type { BackgroundTaskInfo } from '#/tools/builtin';
import type { ContentPart } from '@moonshot-ai/kosong';
import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin';
import type { PluginInfo, PluginReloadResult, PluginSummary } from '#/plugin';
import type { UsageStatus } from './events';
import type { WithAgentId, WithSessionId } from './types';
@ -238,9 +238,13 @@ export interface GetPluginInfoPayload {
readonly id: string;
}
export type ReloadPluginsResult = ReloadSummary;
export type ReloadPluginsResult = PluginReloadResult;
export type { PluginSummary, PluginInfo };
export interface ReloadPluginsPayload {
readonly sessionId: string;
}
export interface RenameSessionPayload {
readonly title: string;
}
@ -320,6 +324,6 @@ export interface CoreAPI extends SessionAPIWithId {
setPluginEnabled: (payload: SetPluginEnabledPayload) => void;
setPluginMcpServerEnabled: (payload: SetPluginMcpServerEnabledPayload) => void;
removePlugin: (payload: RemovePluginPayload) => void;
reloadPlugins: (payload: EmptyPayload) => ReloadPluginsResult;
reloadPlugins: (payload: ReloadPluginsPayload) => ReloadPluginsResult;
getPluginInfo: (payload: GetPluginInfoPayload) => PluginInfo;
}

View file

@ -60,6 +60,7 @@ import type {
PromptPayload,
ReconnectMcpServerPayload,
RegisterToolPayload,
ReloadPluginsPayload,
ReloadPluginsResult,
RemoveKimiProviderPayload,
RemovePluginPayload,
@ -602,11 +603,11 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
await this.plugins.remove(id);
}
async reloadPlugins(_: EmptyPayload): Promise<ReloadPluginsResult> {
async reloadPlugins({ sessionId }: ReloadPluginsPayload): Promise<ReloadPluginsResult> {
let summary: ReloadPluginsResult;
try {
const summary = await this.plugins.reload();
summary = await this.plugins.reload();
this.pluginsLoadError = undefined;
return summary;
} catch (error) {
this.pluginsLoadError = error instanceof Error ? error : new Error(String(error));
throw new KimiError(
@ -615,6 +616,18 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
{ cause: error, details: { kimiHomeDir: this.homeDir } },
);
}
// Hot-apply the refreshed plugin set to the session that asked for the
// reload. The manager is shared across sessions, but applying is
// per-session — other live sessions reconcile on their own reload. A
// failure here must NOT poison `pluginsLoadError`: the manager reloaded
// fine, so plugin management stays usable; the apply error surfaces on its
// own without the misleading "fix installed.json" guidance.
const session = this.sessions.get(sessionId);
const applied = session
? await session.applyPluginRuntimeSnapshot(this.plugins.runtimeSnapshot())
: undefined;
return { ...summary, applied };
}
async getPluginInfo({ id }: GetPluginInfoPayload): Promise<PluginInfo> {

View file

@ -19,7 +19,11 @@ import {
type McpServerEntry,
type SessionMcpConfig,
} from '../mcp';
import type { EnabledPluginSessionStart } from '../plugin';
import type {
EnabledPluginSessionStart,
PluginRuntimeApplyResult,
PluginRuntimeSnapshot,
} from '../plugin';
import {
DEFAULT_AGENT_PROFILES,
DEFAULT_INIT_PROMPT,
@ -98,6 +102,11 @@ export class Session {
readonly hookEngine: HookEngine;
private agentIdCounter = 0;
private readonly skillsReady: Promise<void>;
// Plugin ids whose skills have been loaded into the registry (initial load +
// every reload). The registry is additive and has no unload path, so a plugin
// id still in this set but absent from a later snapshot means its skills are
// now stale and only a new session can drop them.
private readonly loadedPluginSkillIds = new Set<string>();
metadata: SessionMeta = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@ -321,6 +330,116 @@ export class Session {
return this.skills.listSkills().map(summarizeSkill);
}
/**
* Hot-apply a plugin runtime snapshot to this live session. Additive only:
* newly enabled plugin skills are loaded and the main agent's system prompt
* is re-rendered so the model sees them; the `Skill` builtin tool is
* refreshed so it appears when skills first become available; and newly
* enabled plugin MCP servers are connected (their tools register via the MCP
* status subscription and are picked up on the next turn). Disabled/removed
* capabilities are NOT torn down and sessionStart skills are NOT injected
* mid-conversation those are reported via `needsNewSession`.
*/
async applyPluginRuntimeSnapshot(
snapshot: PluginRuntimeSnapshot,
): Promise<PluginRuntimeApplyResult> {
await this.skillsReady;
// Skills: re-resolve roots with the snapshot's plugin roots and merge them
// in. loadRoots skips already-loaded roots, so only new ones are scanned.
const before = new Set(this.skills.listSkills().map((skill) => skill.name));
const roots = await resolveSkillRoots({
paths: {
userHomeDir: this.options.skills?.userHomeDir ?? homedir(),
workDir: this.options.kaos.getcwd(),
},
explicitDirs: this.options.skills?.explicitDirs,
extraDirs: this.options.skills?.extraDirs,
pluginSkillRoots: snapshot.pluginSkillRoots,
mergeAllAvailableSkills: this.options.skills?.mergeAllAvailableSkills,
builtinDir: this.options.skills?.builtinDir,
});
await this.skills.loadRoots(roots);
this.rememberLoadedPluginSkills(snapshot.pluginSkillRoots);
const addedSkills = this.skills
.listSkills()
.map((skill) => skill.name)
.filter((name) => !before.has(name));
// Builtin tools: rebuild so the `Skill` tool appears once skills exist.
this.refreshAgentBuiltinTools();
// System prompt: re-render the main agent's prompt so the model sees the
// new skills. Active tools are intentionally left untouched.
const main = this.agents.get('main');
const profile = DEFAULT_AGENT_PROFILES['agent'];
if (main !== undefined && profile !== undefined) {
const context = await prepareSystemPromptContext(main.kaos);
main.rerenderSystemPrompt(profile, context);
}
// MCP: connect only servers not already present; never reconnect existing.
const existingServers = new Set(this.mcp.list().map((entry) => entry.name));
const newServers = Object.fromEntries(
Object.entries(snapshot.mcpServers).filter(([name]) => !existingServers.has(name)),
);
const newServerNames = Object.keys(newServers);
if (newServerNames.length > 0) {
await this.mcp.connect(newServers);
}
// Report only servers that actually came online. connect() awaits each
// spawn, so by now every entry has settled; a failed / needs-auth server
// must not be announced as "now active".
const addedMcpServers = newServerNames.filter(
(name) => this.mcp.get(name)?.status === 'connected',
);
return {
addedSkills,
addedMcpServers,
needsNewSession: this.pluginRuntimeNeedsNewSession(snapshot, main),
};
}
/**
* Whether the live session still differs from the snapshot in a way only a
* new session can reconcile: skills from a now-disabled/removed plugin still
* loaded in the registry, a plugin MCP server still connected but no longer
* enabled (disable/remove can't be cleanly torn down), or a drift between the
* desired and currently-active sessionStart injections.
*/
private pluginRuntimeNeedsNewSession(
snapshot: PluginRuntimeSnapshot,
main: Agent | undefined,
): boolean {
// Skills: loadRoots is additive, so a plugin whose skills are still in the
// registry but is no longer in the snapshot leaves stale skills behind.
const desiredSkillPlugins = new Set(
snapshot.pluginSkillRoots
.map((root) => root.plugin?.id)
.filter((id): id is string => id !== undefined),
);
const stalePluginSkills = [...this.loadedPluginSkillIds].some(
(id) => !desiredSkillPlugins.has(id),
);
if (stalePluginSkills) return true;
const desiredServers = new Set(Object.keys(snapshot.mcpServers));
const stalePluginServer = this.mcp
.list()
.some((entry) => entry.name.startsWith('plugin-') && !desiredServers.has(entry.name));
if (stalePluginServer) return true;
const activeStarts = new Set(
(main?.pluginSessionStarts ?? []).map((start) => `${start.pluginId}:${start.skillName}`),
);
const desiredStarts = snapshot.sessionStarts.map(
(start) => `${start.pluginId}:${start.skillName}`,
);
if (desiredStarts.length !== activeStarts.size) return true;
return desiredStarts.some((key) => !activeStarts.has(key));
}
private async loadSkills(): Promise<void> {
const roots = await resolveSkillRoots({
paths: {
@ -335,6 +454,13 @@ export class Session {
});
await this.skills.loadRoots(roots);
registerBuiltinSkills(this.skills);
this.rememberLoadedPluginSkills(this.options.skills?.pluginSkillRoots);
}
private rememberLoadedPluginSkills(roots: readonly SkillRoot[] | undefined): void {
for (const root of roots ?? []) {
if (root.plugin?.id !== undefined) this.loadedPluginSkillIds.add(root.plugin.id);
}
}
private async loadMcpServers(): Promise<void> {

View file

@ -0,0 +1,222 @@
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createRPC,
KimiCore,
type ApprovalResponse,
type CoreAPI,
type CoreRPC,
type Event,
type SDKAPI,
} from '../../src';
// A provider + model so a session created WITH a model has a real provider
// (and therefore initializes builtin tools, incl. the gated Skill tool). No
// default_model: sessions created without a model stay provider-less, so the
// other tests' behavior is unchanged.
const CONFIG = `
[providers."test-provider"]
type = "kimi"
api_key = "test-key"
base_url = "https://api.example/v1"
[models."test/model"]
provider = "test-provider"
model = "test-model"
max_context_size = 1000000
`;
describe('plugin reload hot-apply to a live session', () => {
let tmp: string;
let homeDir: string;
let workDir: string;
let configPath: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-plugin-reload-'));
homeDir = join(tmp, 'home');
workDir = join(tmp, 'work');
configPath = join(tmp, 'config.toml');
await mkdir(workDir, { recursive: true });
await writeFile(configPath, CONFIG);
// Hermetic OS home so the developer's real ~/.kimi-code skills don't leak
// into the session and pre-populate invocable skills (which would defeat
// the "Skill tool appears only after hot-load" assertion).
const osHome = join(tmp, 'os-home');
await mkdir(osHome, { recursive: true });
vi.stubEnv('HOME', osHome);
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it('makes a newly installed plugin skill available and visible to the model after reload', async () => {
const { core, rpc } = await createTestRpc();
const created = await rpc.createSession({ id: 'ses_reload_skill', workDir });
// The skill does not exist before the plugin is installed.
const before = await rpc.listSkills({ sessionId: created.id });
expect(before.some((skill) => skill.name === 'hotpack-review')).toBe(false);
const pluginRoot = await makePlugin('hotpack', { skillNames: ['hotpack-review'] });
await rpc.installPlugin({ source: pluginRoot });
// Installing alone does not touch the already-running session.
const afterInstall = await rpc.listSkills({ sessionId: created.id });
expect(afterInstall.some((skill) => skill.name === 'hotpack-review')).toBe(false);
// Reload hot-applies the plugin to this session.
const result = await rpc.reloadPlugins({ sessionId: created.id });
expect(result.applied?.addedSkills).toContain('hotpack-review');
// The skill is now listed...
const afterReload = await rpc.listSkills({ sessionId: created.id });
expect(afterReload.some((skill) => skill.name === 'hotpack-review')).toBe(true);
// ...and the main agent's system prompt was re-rendered so the model knows.
const main = core.sessions.get(created.id)?.agents.get('main');
expect(main?.config.systemPrompt).toContain('hotpack-review');
});
it('exposes the Skill builtin tool to the model only after a skill is hot-loaded', async () => {
const { rpc } = await createTestRpc();
// A model gives the main agent a provider, so builtin tools initialize and
// the gated Skill tool can appear once an invocable skill exists.
const created = await rpc.createSession({ id: 'ses_reload_skilltool', workDir, model: 'test/model' });
// With zero invocable skills, the Skill tool is gated out of the tool set.
const before = await rpc.getTools({ sessionId: created.id, agentId: 'main' });
expect(before.some((tool) => tool.name === 'Skill')).toBe(false);
const pluginRoot = await makePlugin('toolpack', { skillNames: ['toolpack-do'] });
await rpc.installPlugin({ source: pluginRoot });
await rpc.reloadPlugins({ sessionId: created.id });
// The hot-loaded skill makes the Skill tool available so the model can call it.
const after = await rpc.getTools({ sessionId: created.id, agentId: 'main' });
expect(after.some((tool) => tool.name === 'Skill')).toBe(true);
});
it('does not report a plugin MCP server that failed to connect as added/active', async () => {
const { core, rpc } = await createTestRpc();
const created = await rpc.createSession({ id: 'ses_reload_mcp', workDir });
const pluginRoot = await makePlugin('datapack', {
mcpServers: { data: { command: 'kimi-nonexistent-mcp-binary' } },
});
await rpc.installPlugin({ source: pluginRoot });
const first = await rpc.reloadPlugins({ sessionId: created.id });
// The bogus command fails fast: the server entry is registered on the
// session but must NOT be reported as "now active".
expect(first.applied?.addedMcpServers).toEqual([]);
const entry = core.sessions.get(created.id)?.mcp.list().find((e) => e.name === 'plugin-datapack:data');
expect(entry?.status).toBe('failed');
expect(first.applied?.needsNewSession).toBe(false);
});
it('flags needsNewSession when a plugin MCP server is disabled but still registered', async () => {
const { rpc } = await createTestRpc();
const created = await rpc.createSession({ id: 'ses_reload_mcp_disable', workDir });
const pluginRoot = await makePlugin('datapack', {
mcpServers: { data: { command: 'kimi-nonexistent-mcp-binary' } },
});
await rpc.installPlugin({ source: pluginRoot });
await rpc.reloadPlugins({ sessionId: created.id });
// Disabling the plugin and reloading leaves the registered server stale —
// the live session can no longer be reconciled without a new session.
await rpc.setPluginEnabled({ id: 'datapack', enabled: false });
const second = await rpc.reloadPlugins({ sessionId: created.id });
expect(second.applied?.addedMcpServers).toEqual([]);
expect(second.applied?.needsNewSession).toBe(true);
});
it('flags needsNewSession when a skills-only plugin is disabled (skills stay loaded)', async () => {
const { rpc } = await createTestRpc();
const created = await rpc.createSession({ id: 'ses_reload_skill_disable', workDir });
const pluginRoot = await makePlugin('skillpack', { skillNames: ['skillpack-task'] });
await rpc.installPlugin({ source: pluginRoot });
const first = await rpc.reloadPlugins({ sessionId: created.id });
expect(first.applied?.addedSkills).toContain('skillpack-task');
expect(first.applied?.needsNewSession).toBe(false);
// Disabling the plugin and reloading: loadRoots is additive so the skill
// stays in the registry. The user must be told a new session is required.
await rpc.setPluginEnabled({ id: 'skillpack', enabled: false });
const second = await rpc.reloadPlugins({ sessionId: created.id });
expect(second.applied?.needsNewSession).toBe(true);
// The stale skill is still listed (not torn down) — exactly why /new is needed.
const skills = await rpc.listSkills({ sessionId: created.id });
expect(skills.some((s) => s.name === 'skillpack-task')).toBe(true);
});
it('flags needsNewSession when a plugin adds a sessionStart not active in this session', async () => {
const { rpc } = await createTestRpc();
const created = await rpc.createSession({ id: 'ses_reload_sessionstart', workDir });
const pluginRoot = await makePlugin('startpack', {
skillNames: ['startpack-intro'],
sessionStartSkill: 'startpack-intro',
});
await rpc.installPlugin({ source: pluginRoot });
// The sessionStart was not present when this session's main agent was
// created, so it cannot be injected mid-conversation — reload must flag it.
const result = await rpc.reloadPlugins({ sessionId: created.id });
expect(result.applied?.needsNewSession).toBe(true);
});
async function makePlugin(
name: string,
options: {
readonly skillNames?: readonly string[];
readonly mcpServers?: Record<string, unknown>;
readonly sessionStartSkill?: string;
} = {},
): Promise<string> {
const root = await mkdtemp(join(tmpdir(), `plugin-${name}-`));
const manifest: Record<string, unknown> = { name };
for (const skillName of options.skillNames ?? []) {
manifest['skills'] = './skills/';
await mkdir(join(root, 'skills', skillName), { recursive: true });
await writeFile(
join(root, 'skills', skillName, 'SKILL.md'),
`---\nname: ${skillName}\ndescription: A hot-loaded skill\n---\nbody`,
'utf8',
);
}
if (options.mcpServers !== undefined) {
manifest['mcpServers'] = options.mcpServers;
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
await writeFile(join(root, 'kimi.plugin.json'), JSON.stringify(manifest), 'utf8');
return realpath(root);
}
async function createTestRpc(): Promise<{ core: KimiCore; events: Event[]; rpc: CoreRPC }> {
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
const events: Event[] = [];
const core = new KimiCore(coreRpc, { homeDir, configPath });
const rpc = await sdkRpc({
emitEvent: (event) => {
events.push(event);
},
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ decision: 'rejected' })),
requestQuestion: vi.fn(async () => null),
toolCall: vi.fn(async () => ({ output: '' })),
});
return { core, events, rpc };
}
});

View file

@ -82,6 +82,30 @@ describe('McpConnectionManager', () => {
}
}, 20000);
it('connect() adds new servers without reconnecting existing ones or resetting initial-load timing', async () => {
const cm = new McpConnectionManager();
try {
await cm.connectAll({ alpha: stdioConfig() });
expect(cm.get('alpha')?.status).toBe('connected');
const initialDuration = cm.initialLoadDurationMs();
const events: McpServerEntry[] = [];
cm.onStatusChange((entry) => events.push(entry));
await cm.connect({ beta: stdioConfig() });
expect(cm.list().map((e) => e.name).toSorted()).toEqual(['alpha', 'beta']);
expect(cm.get('beta')?.status).toBe('connected');
// The incremental connect must not touch the already-connected server...
expect(events.some((entry) => entry.name === 'alpha')).toBe(false);
expect(events.some((entry) => entry.name === 'beta')).toBe(true);
// ...nor reset the initial-load timing metrics.
expect(cm.initialLoadDurationMs()).toBe(initialDuration);
} finally {
await cm.shutdown();
}
}, 20000);
it('isolates failures: a bad server is marked failed without blocking the rest', async () => {
const cm = new McpConnectionManager();
try {

View file

@ -78,6 +78,43 @@ describe('PluginManager', () => {
expect(manager.get('demo')?.originalSource).toBe(pluginRoot);
});
it('runtimeSnapshot() bundles an enabled plugin\'s skills, MCP servers, and sessionStarts', async () => {
const home = await makeKimiHome();
const pluginRoot = await makePlugin('demo', {
skillNames: ['demo-skill'],
sessionStartSkill: 'demo-skill',
mcpServers: { finance: { command: 'finance-mcp' } },
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(pluginRoot);
const snapshot = manager.runtimeSnapshot();
expect(snapshot.pluginSkillRoots.some((root) => root.plugin?.id === 'demo')).toBe(true);
expect(snapshot.mcpServers).toHaveProperty('plugin-demo:finance');
expect(snapshot.sessionStarts).toContainEqual({ pluginId: 'demo', skillName: 'demo-skill' });
});
it('runtimeSnapshot() omits a disabled plugin', async () => {
const home = await makeKimiHome();
const pluginRoot = await makePlugin('demo', {
skillNames: ['demo-skill'],
sessionStartSkill: 'demo-skill',
mcpServers: { finance: { command: 'finance-mcp' } },
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(pluginRoot);
await manager.setEnabled('demo', false);
const snapshot = manager.runtimeSnapshot();
expect(snapshot.pluginSkillRoots).toEqual([]);
expect(snapshot.mcpServers).toEqual({});
expect(snapshot.sessionStarts).toEqual([]);
});
it('install() accepts a .kimi-plugin manifest', async () => {
const home = await makeKimiHome();
const root = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-'));

View file

@ -92,7 +92,7 @@ describe('KimiCore plugin RPCs', () => {
JSON.stringify({ version: 1, plugins: [] }),
'utf8',
);
await core.reloadPlugins({});
await core.reloadPlugins({ sessionId: 'no-session' });
await expect(core.listPlugins({})).resolves.toEqual([]);
});

View file

@ -35,8 +35,8 @@ import type {
McpStartupMetrics,
PermissionMode,
PluginInfo,
PluginReloadResult,
PluginSummary,
ReloadSummary,
CompactOptions,
SessionPlan,
SessionStatus,
@ -464,9 +464,9 @@ export class SDKRpcClient {
return rpc.removePlugin({ id });
}
async reloadPlugins(): Promise<ReloadSummary> {
async reloadPlugins(input: SessionIdRpcInput): Promise<PluginReloadResult> {
const rpc = await this.getRpc();
return rpc.reloadPlugins({});
return rpc.reloadPlugins({ sessionId: input.sessionId });
}
async getPluginInfo(id: string): Promise<PluginInfo> {

View file

@ -8,9 +8,9 @@ import type {
McpStartupMetrics,
PermissionMode,
PluginInfo,
PluginReloadResult,
PluginSummary,
PromptInput,
ReloadSummary,
ResumedSessionState,
SessionPlan,
SessionStatus,
@ -312,9 +312,9 @@ export class Session {
await this.rpc.removePlugin(id);
}
async reloadPlugins(): Promise<ReloadSummary> {
async reloadPlugins(): Promise<PluginReloadResult> {
this.ensureOpen();
return this.rpc.reloadPlugins();
return this.rpc.reloadPlugins({ sessionId: this.id });
}
async getPluginInfo(id: string): Promise<PluginInfo> {

View file

@ -33,6 +33,8 @@ export type {
OAuthRef,
PluginInfo,
PluginMcpServerInfo,
PluginReloadResult,
PluginRuntimeApplyResult,
PluginSummary,
PromptOrigin,
ProviderConfig,