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
6 changes: 6 additions & 0 deletions .changeset/capability-sessionless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---

Fix built-in capability availability and installed status in `/plugins`, preserve legacy WebBridge skills as backups during updates, and prevent Computer Use updates from duplicating or disconnecting MCP servers.
3 changes: 3 additions & 0 deletions apps/kimi-code/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ if (externalUrl !== undefined && externalUrl.length > 0) {
const inherited = process.env[MARKETPLACE_ENV]?.trim();
marketplaceServer = await startPluginMarketplaceServer();
env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
// Marks the URL as the dev server's own (serving this repo's catalog), so
// the CLI can tell it apart from a user-configured marketplace override.
env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] = '1';
console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) {
console.error(
Expand Down
163 changes: 107 additions & 56 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { homedir as osHomedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';

import type { CapabilityStatus, PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk';
import {
log,
type CapabilityStatus,
type PluginInfo,
type PluginSummary,
type Session,
} from '@moonshot-ai/kimi-code-sdk';

import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
PluginRemoveConfirmComponent,
PluginsPanelComponent,
describeCapabilityIssues,
formatCapabilityVersion,
type PluginInstallTrustConfirmResult,
type PluginMcpSelection,
type PluginRemoveConfirmResult,
Expand Down Expand Up @@ -195,6 +199,44 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
}
}

/**
* Resolve the capability API. Like plugin state, capability state is
* app-global on the v2 engine, so a session-less startup still gets
* readiness and installs through the harness's global facade; with a live
* session the session's own API is used (v1 included, where the capability
* surface then reports itself unavailable).
*/
type CapabilityApi = Pick<Session, 'listCapabilities' | 'getCapability' | 'installCapability'>;

async function resolveCapabilityApi(host: SlashCommandHost): Promise<CapabilityApi> {
if (host.session !== undefined) return host.session;
if (!host.engineV2) {
throw new Error(NO_ACTIVE_SESSION_MESSAGE);
}
return host.harness;
}

function logCapabilityStatus(capability: CapabilityStatus, installed?: boolean): void {
const payload = {
capabilityId: capability.id,
installed,
supported: capability.supported,
state: capability.state,
version: capability.version,
install: capability.install,
steps: capability.steps,
};
const hasStepIssues = capability.steps.some((step) => step.state !== 'ok');
if (
capability.install.error !== undefined ||
(installed !== false && hasStepIssues)
) {
log.warn('capability needs attention', payload);
} else {
log.info('capability status', payload);
}
}

async function showPluginsPicker(
host: SlashCommandHost,
options?: ShowPluginsPickerOptions,
Expand All @@ -210,22 +252,22 @@ async function showPluginsPicker(
let capabilities: readonly CapabilityStatus[] = [];
if (host.engineV2) {
try {
capabilities = await host.requireSession().listCapabilities();
capabilities = await (await resolveCapabilityApi(host)).listCapabilities();
} catch (error) {
host.showStatus(
`Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`,
'warning',
);
log.warn('capability status unavailable', { error });
}
}

const installedIds = new Set(plugins.map((plugin) => plugin.id));
for (const capability of capabilities) {
logCapabilityStatus(capability, installedIds.has(capability.id));
}

const panel = new PluginsPanelComponent({
installed: plugins,
installedIds: new Set(plugins.map((plugin) => plugin.id)),
installedIds,
capabilities,
catalogIsDefault:
options?.marketplaceSource === undefined &&
process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined,
catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource),
initialTab: options?.initialTab,
selectedId: options?.selectedId,
pluginHint: options?.pluginHint,
Expand Down Expand Up @@ -278,23 +320,33 @@ function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketp
};
}

/**
* Injection is part of the DEFAULT catalog experience only: any explicit
* replacement (the slash-command source or a user-set env override) opts out
* wholesale. The dev marketplace server started by scripts/dev.mjs serves
* this repo's own catalog and marks itself, so it still counts as default.
*/
function isDefaultMarketplaceCatalog(
source: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (source !== undefined) return false;
if (env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined) return true;
return env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1';
}

async function loadMarketplaceCatalog(
host: SlashCommandHost,
panel: PluginsPanelComponent,
source: string | undefined,
capabilities: readonly CapabilityStatus[],
): Promise<void> {
try {
// Injection is part of the DEFAULT catalog experience only: any explicit
// replacement (the slash-command source or the env override) opts out
// wholesale — its same-id rows are never masked and its failures surface.
const isDefaultCatalog =
source === undefined && process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined;
const marketplace = await loadPluginMarketplace({
workDir: host.state.appState.workDir,
source,
builtInEntries:
host.engineV2 && isDefaultCatalog
host.engineV2 && isDefaultMarketplaceCatalog(source)
? capabilities.map(capabilityMarketplaceEntry)
: undefined,
});
Expand Down Expand Up @@ -403,34 +455,36 @@ function isCapabilityId(host: SlashCommandHost, id: string): boolean {
return host.engineV2 && (id === 'kimi-cu' || id === 'kimi-webbridge');
}

/** Poll a background capability install, mirroring progress into the
* panel's inline installing line until it settles (or we run out of budget). */
/** Poll a background capability install until it settles (or we run out of budget). */
async function pollCapabilityInstall(
host: SlashCommandHost,
panel: PluginsPanelComponent,
id: string,
label: string,
): Promise<CapabilityStatus | undefined> {
const session = host.requireSession();
const api = await resolveCapabilityApi(host);
let previousProgress = '';
for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) {
await new Promise((resolve) => {
setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS);
});
const status = await session.getCapability(id);
const status = await api.getCapability(id);
if (!status.install.running) return status;
const step = status.install.step ?? 'configuring runtime';
const percent = status.install.percent;
panel.setInstalling(
`${truncateForStatus(label)} — ${step}${percent !== undefined ? ` ${percent}%` : ''}`,
);
host.state.ui.requestRender();
const progress = `${status.install.step ?? ''}:${status.install.percent ?? ''}`;
if (progress !== previousProgress) {
previousProgress = progress;
log.info('capability install progress', {
capabilityId: id,
step: status.install.step,
percent: status.install.percent,
});
}
}
return undefined;
}

export const __pluginsCommandInternals = {
isCapabilityEntry,
installCapabilityFromPanel,
isDefaultMarketplaceCatalog,
pollCapabilityInstall,
removePlugin,
};
Expand All @@ -445,18 +499,22 @@ async function installCapabilityFromPanel(
// reserved for unreviewed third-party plugins.
panel.setInstalling(truncateForStatus(label));
host.state.ui.requestRender();
const session = host.requireSession();
const api = await resolveCapabilityApi(host);
log.info('capability install requested', { capabilityId: entry.id });
try {
// An install already running (started from another panel or client) is
// followed, not restarted — the service rejects duplicate starts even
// though the original is healthy.
const alreadyRunning = await session
const alreadyRunning = await api
.getCapability(entry.id)
.then((status) => status.install.running, () => false);
if (!alreadyRunning) {
await session.installCapability(entry.id);
await api.installCapability(entry.id);
} else {
log.info('following running capability install', { capabilityId: entry.id });
}
} catch (error) {
log.warn('capability install failed to start', { capabilityId: entry.id, error });
panel.clearInstalling();
host.state.ui.requestRender();
host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`);
Expand All @@ -465,49 +523,42 @@ async function installCapabilityFromPanel(
}
let result: CapabilityStatus | undefined;
try {
result = await pollCapabilityInstall(host, panel, entry.id, label);
} catch {
result = await pollCapabilityInstall(host, entry.id);
} catch (error) {
log.warn('capability install polling failed', { capabilityId: entry.id, error });
result = undefined;
}
panel.clearInstalling();
// Close the panel so the result lines land in the transcript, matching the
// plain plugin install flow.
host.restoreEditor();
if (result === undefined) {
host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`);
host.showStatus(`${label} installation is still running in the background.`);
return;
}
logCapabilityStatus(result);
if (result.install.error !== undefined) {
host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`);
host.showError(`${label} installation failed. Check the logs and install again from /plugins.`);
return;
}
if (result.state !== 'ready') {
const issues = describeCapabilityIssues(result);
host.showStatus(
`${label} setup is incomplete${issues.length > 0 ? `: ${issues}` : ''}.`,
'warning',
);
if (result.id === 'kimi-cu' && result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok')) {
const permissionsRequired =
entry.id === 'kimi-cu' &&
result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok');
if (permissionsRequired) {
host.showStatus(
'Grant Accessibility and Screen Recording in System Settings → Privacy & Security, then reopen /plugins to recheck.',
'Grant Accessibility and Screen Recording in System Settings → Privacy & Security.',
'warning',
);
} else {
host.showError(
`${label} installation did not complete. Check the logs and install again from /plugins.`,
);
}
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
return;
}
host.showStatus(
`${label} is ready${result.version !== undefined ? ` (${formatCapabilityVersion(result.version)})` : ''}.`,
);
const skillShadow = result.steps.find(
(step) => step.id === 'skill-shadow' && step.state !== 'ok',
);
if (skillShadow?.detail !== undefined) {
host.showStatus(
`A user-installed kimi-webbridge skill is shadowing the managed plugin. Remove it manually: ${skillShadow.detail}`,
'warning',
);
}
host.showStatus(`${label} is installed.`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}

Expand Down
Loading
Loading