-
Notifications
You must be signed in to change notification settings - Fork 909
feat(cli): add plugin quota and update notices #2147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea5934e
eee4396
68c63ca
b928b6b
0cc6657
f4ddcc1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| 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. |
| 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; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if (entry === undefined) return; | ||
| const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); | ||
|
Comment on lines
+170
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 } }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a turn uses two different outdated plugins, Useful? React with 👍 / 👎. |
||
| this.deps.stateFile, | ||
|
Comment on lines
+185
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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. | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
|
|
@@ -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(); | ||
| } | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an outdated plugin MCP tool is used by a child agent, Useful? React with 👍 / 👎. |
||
| this.pluginMcpToolsUsedInTurn.add(matchedCall.name); | ||
|
Comment on lines
+621
to
+624
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an outdated plugin MCP tool is invoked by a subagent, its events return through Useful? React with 👍 / 👎. |
||
| } | ||
| this.subAgentEventHandler.handleAgentSwarmToolResult( | ||
| event.toolCallId, | ||
| resultData, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the first plugin MCP tool completes, this memoizes the server-to-plugin map permanently. The TUI reuses the same
SessionEventHandler/PluginUpdateNotifieracross/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 toundefinedand the new update notice is silently missed until restart.Useful? React with 👍 / 👎.