Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-quota-note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show a quota consumption note after installing official plugins that bill against plan quota (such as Kimi Datasource).
5 changes: 5 additions & 0 deletions .changeset/plugin-update-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show an update notice when a turn that used an outdated plugin ends and the Official Marketplace has a newer version; each new version is announced once. Run /plugins to install the latest version.
4 changes: 4 additions & 0 deletions apps/kimi-code/src/constant/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json';
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
Expand Down Expand Up @@ -79,6 +80,9 @@ export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
// Official plugins whose usage bills against the user's plan quota. Installing
// one of these shows a quota note after the install result.
export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource'];
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;
export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`;
// Official download page, referenced by prompt copy that steers users away
Expand Down
14 changes: 13 additions & 1 deletion apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import {
} from '../components/messages/plugins-status-panel';
import { UsagePanelComponent } from '../components/messages/usage-panel';
import { formatErrorMessage } from '../utils/event-payload';
import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label';
import {
formatPluginSourceLabel,
isOfficialPluginInstall,
isOfficialPluginSource,
} from '../utils/plugin-source-label';
import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app';
import { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import { openUrl } from '#/utils/open-url';
import type { SlashCommandHost } from './dispatch';
Expand Down Expand Up @@ -492,6 +497,8 @@ async function installPluginFromSource(

const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.';

const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.';

function showPluginInstallResult(
host: SlashCommandHost,
beforeList: readonly PluginSummary[],
Expand All @@ -506,6 +513,11 @@ function showPluginInstallResult(
const action = describeInstallAction(previous, summary);
host.showStatus(`${action} (${summary.id}).${mcpHint}`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
// Gate on provenance, not just the id: a local/GitHub fork whose manifest
// reuses a billed plugin's id is not the official quota-consuming build.
if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) {
host.showStatus(PLUGIN_QUOTA_NOTE, 'warning');
}
}

function describeInstallAction(
Expand Down
208 changes: 208 additions & 0 deletions apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk';

import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
import {
computeUpdateStatus,
loadPluginMarketplace,
type PluginMarketplace,
} from '#/utils/plugin-marketplace';
import {
readPluginUpdateNoticeState,
writePluginUpdateNoticeState,
} from '#/utils/plugin-update-notice-state';
import { isOfficialPluginInstall } from '../utils/plugin-source-label';

/**
* The slice of the SDK session the notifier reads. Structurally satisfied by
* the full SDK `Session`, and easy to fake in tests.
*/
export interface PluginUpdateNotifierSession {
listMcpServers(): Promise<readonly { name: string }[]>;
listPlugins(): Promise<readonly PluginSummary[]>;
}

export interface PluginUpdateNotifierDeps {
readonly getSession: () => PluginUpdateNotifierSession | undefined;
readonly workDir: string;
readonly notify: (message: string) => void;
/** Overridable for tests; defaults to the shared marketplace loader. */
readonly loadMarketplace?: () => Promise<PluginMarketplace>;
/** Overridable for tests; defaults to the updates dir under the data dir. */
readonly stateFile?: string;
}

const MCP_TOOL_NAME_PREFIX = 'mcp__';
const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`;
// Plugin MCP servers run under the runtime name `plugin-<id>:<server>`
// (pluginMcpRuntimeName in packages/agent-core/src/plugin/manager.ts).
const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/;

/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */
export function isPluginMcpToolName(toolName: string): boolean {
return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX);
}

/**
* Mirror of sanitizeMcpNamePart in packages/agent-core/src/mcp/tool-naming.ts.
* MCP tool names on the wire carry the sanitized server name; the collapse
* step guarantees the `__` separator never appears inside a name part.
*/
function sanitizeMcpServerName(name: string): string {
return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_');
}

/**
* Find the plugin behind a qualified MCP tool name by longest-prefix match
* against known server names. Prefix matching (rather than splitting on the
* `__` separator) survives core's 64-char truncation, which can cut the
* separator before appending the hash suffix; the boundary check keeps a
* shorter server name from matching another server's name, and longest match
* wins when one server name is a prefix of another. A name truncated inside
* the server part itself cannot be attributed reliably and stays unresolved.
*/
function matchPluginByToolName(
toolName: string,
serverPluginIds: Map<string, string>,
): string | undefined {
let best: string | undefined;
let bestLength = 0;
for (const [serverName, pluginId] of serverPluginIds) {
const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`;
if (!toolName.startsWith(prefix)) continue;
const boundary = toolName.charAt(prefix.length);
if (boundary !== '' && boundary !== '_') continue;
if (prefix.length > bestLength) {
best = pluginId;
bestLength = prefix.length;
}
}
return best;
}

/**
* Shows a one-time "update detected" notice for outdated plugins. Callers
* report completed plugin usage (a plugin MCP tool name, or the plugin id of
* a `/<plugin>:<command>` turn — both reported once the turn's output has
* ended); the notifier checks the marketplace and persists the last notified
* version, so a plugin is re-notified only when the marketplace advertises a
* newer version than the one already shown.
*
* Entry points are fire-and-forget in production (never reject — the notice
* is a background nicety and any failure, e.g. an offline marketplace, is
* swallowed) and return an awaitable promise so tests can settle the queue
* deterministically.
*/
export class PluginUpdateNotifier {
private marketplacePromise: Promise<PluginMarketplace> | undefined;
private mcpServerPluginIds: Map<string, string> | undefined;
private readonly inFlight = new Set<string>();
private queue: Promise<void> = Promise.resolve();

constructor(private readonly deps: PluginUpdateNotifierDeps) {}

handleMcpToolCompleted(toolName: string): Promise<void> {
// Cheap bail before touching the RPC layer — most tools are not MCP tools,
// let alone plugin ones.
if (!isPluginMcpToolName(toolName)) return Promise.resolve();
return this.resolvePluginId(toolName)
.then((pluginId) => {
if (pluginId !== undefined) return this.enqueue(pluginId);
return undefined;
})
.catch(() => {});
}

handlePluginCommandCompleted(pluginId: string): Promise<void> {
return this.enqueue(pluginId);
}

private enqueue(pluginId: string): Promise<void> {
// Serialize the read-modify-write cycle on the notice state file: two
// concurrent checks (e.g. a turn that used two outdated plugins) would
// otherwise read the same snapshot and the last write would drop the
// other plugin's entry.
this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {});
return this.queue;
}

private async resolvePluginId(toolName: string): Promise<string | undefined> {
const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds());
if (hit !== undefined) return hit;
// The map is memoized, but this notifier is reused across /reload, /new,
// and session switches, so plugins installed or enabled later in the same
// app run are missing from it. Refresh once on a miss before giving up.
return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds());
}

private async getMcpServerPluginIds(): Promise<Map<string, string>> {
if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate plugin MCP cache when sessions change

When the first plugin MCP tool completes, this memoizes the server-to-plugin map permanently. The TUI reuses the same SessionEventHandler/PluginUpdateNotifier across /reload, /new, and session switches, so after a user installs or enables another plugin later in the same app run, that plugin's runtime server name is never added to this map; its MCP tool completions resolve to undefined and the new update notice is silently missed until restart.

Useful? React with 👍 / 👎.

return this.loadMcpServerPluginIds();
}

private async loadMcpServerPluginIds(): Promise<Map<string, string>> {
const map = new Map<string, string>();
const session = this.deps.getSession();
// Without a session there is nothing to list; leave the cache unset so
// the next lookup retries instead of pinning an empty map.
if (session === undefined) return map;
const servers = await session.listMcpServers();
for (const server of servers) {
const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name);
if (match?.[1] !== undefined) {
map.set(sanitizeMcpServerName(server.name), match[1]);
}
}
this.mcpServerPluginIds = map;
return map;
}

private async checkAndNotify(pluginId: string): Promise<void> {
if (this.inFlight.has(pluginId)) return;
this.inFlight.add(pluginId);
try {
const session = this.deps.getSession();
if (session === undefined) return;
const marketplace = await this.loadCatalog();
// Only the default official catalog can back an "Official Marketplace"
// notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may
// advertise anything under any id.
if (marketplace.source !== KIMI_CODE_PLUGIN_MARKETPLACE_URL) return;
const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId);
Comment on lines +165 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject custom catalogs before issuing official update notices

When KIMI_CODE_PLUGIN_MARKETPLACE_URL points to a custom catalog, this lookup accepts any matching entry without validating the catalog or entry provenance. If an official plugin is installed and that custom catalog advertises the same ID at a higher version, the CLI emits a notice claiming the version comes from the Official Marketplace and directs the user toward that custom entry. Restrict this notifier to the default official catalog or verify the matched entry's official source before announcing it.

Useful? React with 👍 / 👎.

if (entry === undefined) return;
const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId);
Comment on lines +170 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict update notices to official plugin sources

When KIMI_CODE_PLUGIN_MARKETPLACE_URL points to a custom catalog, or when a locally installed fork shares an ID with a catalog entry, this ID-only lookup treats that plugin as official and the notice explicitly directs the user to the “Official Marketplace.” loadPluginMarketplace() honors the custom-marketplace environment override, and the installed summary's source/originalSource is never checked, so a third-party update can be misrepresented as official; validate both catalog and installed provenance or explicitly load the official catalog.

Useful? React with 👍 / 👎.

if (installed === undefined) return;
// Only official installs are tracked against the Official Marketplace —
// a local/GitHub fork that happens to share a catalog id is not it.
if (!isOfficialPluginInstall(installed)) return;
const status = computeUpdateStatus(entry.version, installed.version, true);
if (status.kind !== 'update') return;
const state = await readPluginUpdateNoticeState(this.deps.stateFile);
if (state.notified[pluginId] === status.latest) return;
Comment on lines +179 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore catalog versions older than the recorded notice

If a user was notified about 3.5.0 and a stale or rolled-back marketplace response later advertises 3.4.0 while the installed version is below both, this equality check treats 3.4.0 as a new notice and overwrites the saved 3.5.0; when 3.5.0 reappears, it is announced again. Compare the candidate with the recorded semver and skip versions that are not newer, rather than checking only equality.

Useful? React with 👍 / 👎.

this.deps.notify(
`Update detected: ${installed.displayName} ${status.latest} is available. ` +
'Run /plugins to install the latest version from the Official Marketplace.',
);
await writePluginUpdateNoticeState(
{ ...state, notified: { ...state.notified, [pluginId]: status.latest } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize notice-state writes for concurrent plugin notices

When a turn uses two different outdated plugins, handleTurnEnd starts separate checks and inFlight only dedupes per plugin, so both checks can read the same state and then each write a new object derived from that stale snapshot. The last write drops the other plugin's notified entry, causing that version to be announced again on the next use even though the feature promises each new version is shown once.

Useful? React with 👍 / 👎.

this.deps.stateFile,
Comment on lines +185 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize notice state across CLI processes

When two CLI instances detect updates concurrently, both can read the same old state at line 170 and then write snapshots derived from it here. For the same plugin this produces duplicate supposedly one-time notices; for different plugins the last rename drops the other entry, causing a later duplicate. The read-modify-write needs a cross-process lock or another atomic merge mechanism, not only the instance-local promise queue.

Useful? React with 👍 / 👎.

);
} finally {
this.inFlight.delete(pluginId);
}
}

private loadCatalog(): Promise<PluginMarketplace> {
// Cached for the app run; a failed fetch is retried on the next invocation.
this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => {
this.marketplacePromise = undefined;
throw error;
});
return this.marketplacePromise;
}

private loadMarketplace(): Promise<PluginMarketplace> {
const load = this.deps.loadMarketplace;
if (load !== undefined) return load();
return loadPluginMarketplace({ workDir: this.deps.workDir });
}
}
47 changes: 44 additions & 3 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { errorReportHintLine } from '../constant/feedback';
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
import { nextTranscriptId } from '../utils/transcript-id';
import type { BtwPanelController } from './btw-panel';
import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier';
import type { StreamingUIController } from './streaming-ui';
import type { TasksBrowserController } from './tasks-browser';
import { SubAgentEventHandler } from './subagent-event-handler';
Expand Down Expand Up @@ -119,15 +120,28 @@ export interface SessionEventHost {

export class SessionEventHandler {
readonly subAgentEventHandler: SubAgentEventHandler;
private readonly pluginUpdateNotifier: PluginUpdateNotifier;

constructor(private readonly host: SessionEventHost) {
constructor(
private readonly host: SessionEventHost,
pluginUpdateNotifier?: PluginUpdateNotifier,
) {
this.subAgentEventHandler = new SubAgentEventHandler(host, {
backgroundTasks: this.backgroundTasks,
backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal,
syncBackgroundAgentBadge: () => {
this.syncBackgroundTaskBadge();
},
});
this.pluginUpdateNotifier =
pluginUpdateNotifier ??
new PluginUpdateNotifier({
getSession: () => this.host.session,
workDir: host.state.appState.workDir,
notify: (message) => {
this.host.showStatus(message, 'warning');
},
});
}

// Runtime state – owned by this handler, reset between sessions.
Expand All @@ -142,6 +156,8 @@ export class SessionEventHandler {
private goalCompletionAwaitingClear = false;
private goalCompletionTurnEnded = false;
private currentTurnHasAssistantText = false;
private pluginCommandTurns: Map<string, string> = new Map();
private pluginMcpToolsUsedInTurn: Set<string> = new Set();
private pendingModelBlockedFallback: GoalChange | undefined;
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
Expand All @@ -158,6 +174,8 @@ export class SessionEventHandler {
this.goalCompletionAwaitingClear = false;
this.goalCompletionTurnEnded = false;
this.currentTurnHasAssistantText = false;
this.pluginCommandTurns.clear();
this.pluginMcpToolsUsedInTurn.clear();
this.pendingModelBlockedFallback = undefined;
this.queuedGoalPromotionPending = false;
this.queuedGoalPromotionInFlight = false;
Expand Down Expand Up @@ -293,9 +311,11 @@ export class SessionEventHandler {
// Private handlers
// ---------------------------------------------------------------------------

private handleTurnBegin(_event: TurnStartedEvent): void {
void _event;
private handleTurnBegin(event: TurnStartedEvent): void {
this.currentTurnHasAssistantText = false;
if (event.origin?.kind === 'plugin_command') {
this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId);
}
this.clearAgentSwarmProgress();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.setStep(0);
Expand Down Expand Up @@ -348,6 +368,22 @@ export class SessionEventHandler {
this.renderPendingModelBlockedFallback();
this.currentTurnHasAssistantText = false;
this.goalCompletionTurnEnded = true;
// Plugin usage is reported once the whole turn's output has ended — but a
// cancelled turn cut the output short, so skip the notice there.
const reportPluginUsage = event.reason !== 'cancelled';
const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId));
if (pluginCommandPluginId !== undefined) {
this.pluginCommandTurns.delete(String(event.turnId));
if (reportPluginUsage) {
void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId);
}
}
if (reportPluginUsage) {
for (const toolName of this.pluginMcpToolsUsedInTurn) {
void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName);
}
}
this.pluginMcpToolsUsedInTurn.clear();
this.scheduleQueuedGoalPromotion();
}

Expand Down Expand Up @@ -582,6 +618,11 @@ export class SessionEventHandler {
synthetic: event.synthetic,
};
const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData);
if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) {
// Buffer plugin MCP usage for the turn; the update notice fires once the
// whole turn's output has ended (see handleTurnEnd).
Comment on lines 620 to +623

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track plugin use by child agents

When an outdated plugin MCP tool is used by a child agent, routeChildAgentEvent returns before execution reaches this buffering block, while SubAgentEventHandler only renders the child's tool.result. Consequently, a user turn whose plugin use occurs exclusively in an Agent/AgentSwarm child finishes without any update notice. Forward child plugin-tool completions into the parent turn's usage buffer as well.

Useful? React with 👍 / 👎.

this.pluginMcpToolsUsedInTurn.add(matchedCall.name);
Comment on lines +621 to +624

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include subagent MCP usage in update detection

When an outdated plugin MCP tool is invoked by a subagent, its events return through routeChildAgentEvent before reaching this collection logic, so the main turn finishes without ever notifying the user. Subagents share the session MCP manager and can invoke these tools, so record qualifying child tool completions in the subagent route as well and flush them when the encompassing main turn ends.

Useful? React with 👍 / 👎.

}
this.subAgentEventHandler.handleAgentSwarmToolResult(
event.toolCallId,
resultData,
Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/src/tui/utils/plugin-source-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ export function isOfficialPluginSource(source: string): boolean {
}
}

/**
* Returns true when an installed plugin provably came from a trusted official
* source — a zip download under the official CDN plugin path. Local paths,
* GitHub repos, and third-party URLs do not qualify, even when their manifest
* id matches an official plugin.
*/
export function isOfficialPluginInstall(plugin: PluginSummary): boolean {
return (
plugin.source === 'zip-url' &&
plugin.originalSource !== undefined &&
isOfficialPluginSource(plugin.originalSource)
);
}

function hostFromUrl(raw: string): string | undefined {
try {
const url = new URL(raw);
Expand Down
Loading
Loading