Merge remote-tracking branch 'origin/main' into feat-interrupt-reminder

# Conflicts:
#	packages/agent-core-v2/docs/state-manifest.d.ts
This commit is contained in:
7Sageer 2026-07-30 17:35:12 +08:00
commit 889cfa0107
217 changed files with 2646 additions and 774 deletions

View file

@ -11,7 +11,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito
- `src/flag/flag.ts``IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts``FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts`**removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`).
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts` or `src/agent/faultInjection/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
## Public surface

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": minor
---
Allow enabled plugins to contribute agent system-prompt instructions through `systemPrompt` or `systemPromptPath` in `kimi.plugin.json`, effective on both agent engines (the TUI, `kimi -p`, and `kimi web`).

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": minor
---
Support Markdown-defined custom agents on agent-core.

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": minor
---
Add the /secondary_model slash command to configure the secondary model used by subagents.

View file

@ -1,5 +1,25 @@
# @moonshot-ai/kimi-code
## 0.31.0
### Minor Changes
- [#2365](https://github.com/MoonshotAI/kimi-code/pull/2365) [`fa2c5ce`](https://github.com/MoonshotAI/kimi-code/commit/fa2c5ce18b70577fa3ada4eb8bdd4993891994ce) Thanks [@7Sageer](https://github.com/7Sageer)! - Add support for plugin-contributed custom agents, discovered automatically and available for sub-agent delegation. Ship an `agents/` directory in the plugin (or declare `agents` paths in the plugin manifest) to provide them.
- [#2314](https://github.com/MoonshotAI/kimi-code/pull/2314) [`02d77b2`](https://github.com/MoonshotAI/kimi-code/commit/02d77b20d941873563f14890e049ffe40cec76e4) Thanks [@7Sageer](https://github.com/7Sageer)! - Allow enabled plugins to contribute agent system-prompt instructions through `systemPrompt` or `systemPromptPath` in `kimi.plugin.json`, effective on both agent engines (the TUI, `kimi -p`, and `kimi web`).
- [#2232](https://github.com/MoonshotAI/kimi-code/pull/2232) [`efac96c`](https://github.com/MoonshotAI/kimi-code/commit/efac96c8a95a3c3ca4e1ae9bce38082498a02b2e) Thanks [@7Sageer](https://github.com/7Sageer)! - Support Markdown-defined custom agents on agent-core.
- [#2232](https://github.com/MoonshotAI/kimi-code/pull/2232) [`efac96c`](https://github.com/MoonshotAI/kimi-code/commit/efac96c8a95a3c3ca4e1ae9bce38082498a02b2e) Thanks [@7Sageer](https://github.com/7Sageer)! - Add the /secondary_model slash command to configure the secondary model used by subagents.
### Patch Changes
- [#2382](https://github.com/MoonshotAI/kimi-code/pull/2382) [`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix request headers not being passed correctly on some requests.
- [#2379](https://github.com/MoonshotAI/kimi-code/pull/2379) [`691ec46`](https://github.com/MoonshotAI/kimi-code/commit/691ec4679ea19d6be8ac18f359088384ed3e446d) Thanks [@RealKai42](https://github.com/RealKai42)! - Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification.
- [#2395](https://github.com/MoonshotAI/kimi-code/pull/2395) [`d10b1c1`](https://github.com/MoonshotAI/kimi-code/commit/d10b1c130813dbd6ee8c8599a6a98feb36aea67f) Thanks [@sailist](https://github.com/sailist)! - Fix sessions missing from the session picker when their cached metadata predates the archived flag.
## 0.30.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.30.0",
"version": "0.31.0",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",

View file

@ -11,13 +11,12 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2';
import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import { type Command } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
@ -25,7 +24,7 @@ import { getDataDir } from '#/utils/paths';
import { initializeServerTelemetry } from '../../telemetry';
import {
buildKimiDefaultHeaders,
createKimiCodeHostIdentity,
getHostPackageRoot,
getVersion,
} from '../../version';
@ -278,7 +277,16 @@ async function runServerInProcess(
port: options.port,
// Report the CLI's product version as `server_version` (/meta, web UI)
// rather than kap-server's private package version.
version,
serverVersion: version,
// The CLI's host identity: feeds the engine's bootstrap client identity
// and the derived outbound headers (User-Agent + X-Msh-*), so web-UI
// OAuth flows and model / WebSearch requests carry the CLI identity. The
// `web` User-Agent suffix distinguishes web-UI traffic from direct CLI
// runs upstream (same product token, same platform).
hostIdentity: {
...createKimiCodeHostIdentity(version),
userAgentSuffix: WEB_USER_AGENT_SUFFIX,
},
logLevel: options.logLevel,
logger,
debugEndpoints: options.debugEndpoints,
@ -291,10 +299,6 @@ async function runServerInProcess(
// `telemetry` toggle). Complements the v1 client registered above, which
// only covers host-level events.
telemetry: true,
// Seed the CLI's Kimi identity headers so the engine's outbound
// requests (model, WebSearch, FetchURL) carry the same User-Agent +
// X-Msh-* identity as direct CLI runs.
seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)),
webAssetsDir,
});
logger.info('serving the REST/WS API and the bundled web UI');

View file

@ -128,7 +128,7 @@ export async function runV2Print(
const identity = createKimiCodeHostIdentity(version);
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
const { app } = bootstrap({ homeDir, clientVersion: version }, [
const { app } = bootstrap({ homeDir, clientIdentity: identity }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
// `--skillsDir` (v1 print parity): explicit skill dirs replace default

View file

@ -7,11 +7,10 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { createKimiUserAgent, KIMI_CODE_PLATFORM, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
import { getDataDir } from '../utils/paths';
import { KIMI_BUILD_INFO } from './build-info';
const MODULE_DIR = import.meta.dirname;
@ -50,8 +49,9 @@ export function getVersion(): string {
export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIdentity {
return {
userAgentProduct: CLI_USER_AGENT_PRODUCT,
productName: CLI_USER_AGENT_PRODUCT,
version,
platform: KIMI_CODE_PLATFORM,
};
}
@ -62,10 +62,3 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden
export function createKimiCodeUserAgent(version = getVersion()): string {
return createKimiUserAgent(createKimiCodeHostIdentity(version));
}
export function buildKimiDefaultHeaders(version: string): Record<string, string> {
return createKimiDefaultHeaders({
homeDir: getDataDir(),
...createKimiCodeHostIdentity(version),
});
}

View file

@ -10,6 +10,10 @@ export const CLI_UI_MODE = 'shell';
// Telemetry ui_mode for the `kimi web` host. Same product
// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
export const WEB_UI_MODE = 'web';
// User-Agent suffix for the `kimi web` host: its requests go out as
// `kimi-code-cli/<version> (web)` so upstream can tell web-UI traffic
// apart from direct CLI runs without changing the product token or platform.
export const WEB_USER_AGENT_SUFFIX = 'web';
// Give telemetry a short flush window without making CLI exit feel stuck.
export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;

View file

@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import { log } from '@moonshot-ai/kimi-code-sdk';
import type {
ApprovalRequest,
ApprovalResponse,
@ -1684,8 +1685,11 @@ export class KimiTUI {
this.state.appState.sessionId,
this.hasSessionContent(),
);
} catch {
/* silently ignore */
} catch (error) {
// The picker must keep working (it renders the empty state), but a
// swallowed failure surfaces as a misleading "No sessions found." —
// keep a log trail so the real error stays discoverable.
log.warn('failed to fetch sessions for picker', { error: String(error) });
} finally {
this.state.loadingSessions = false;
}

View file

@ -267,7 +267,7 @@ describe('runShell', () => {
expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith(
expect.objectContaining({
identity: expect.objectContaining({
userAgentProduct: 'kimi-code-cli',
productName: 'kimi-code-cli',
version: '1.2.3-test',
}),
sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false },

View file

@ -205,7 +205,11 @@ function makeFakeHarness() {
{
platform: 'linux',
arch: 'x64',
clientVersion: '1.2.3-test',
clientIdentity: {
productName: 'test-product',
version: '1.2.3-test',
platform: 'test_platform',
},
osHomeDir: '/home/test',
getEnv: () => undefined,
},

View file

@ -4,7 +4,6 @@ import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
buildKimiDefaultHeaders,
createKimiCodeUserAgent,
getHostPackageJsonPath,
getHostPackageRoot,
@ -21,12 +20,6 @@ describe('cli version helpers', () => {
expect(getVersion()).toBe(pkg.version);
});
it('builds default headers with the kimi-code-cli user-agent', () => {
const headers = buildKimiDefaultHeaders('1.2.3');
expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3');
});
it('builds the product user-agent for ad-hoc fetches', () => {
expect(createKimiCodeUserAgent('1.2.3')).toBe('kimi-code-cli/1.2.3');
});

View file

@ -1,9 +1,20 @@
# Changelog
## 0.6.6
### Patch Changes
- [#2393](https://github.com/MoonshotAI/kimi-code/pull/2393) [`6d0a046`](https://github.com/MoonshotAI/kimi-code/commit/6d0a046488edda56219961b253c4787abae7a113) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix new users getting stranded on "Model setup required" with no way back to sign-in when the first login finishes authorization but fails to complete model setup; the screen now offers a path back to the sign-in page so login can be retried.
- [#2402](https://github.com/MoonshotAI/kimi-code/pull/2402) [`0f3b106`](https://github.com/MoonshotAI/kimi-code/commit/0f3b106c4260ad626f66bc5c457a535d3163f2bc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication".
- Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
- @moonshot-ai/kimi-code-sdk@0.15.0
## 0.6.5
### Patch Changes
- [#1994](https://github.com/MoonshotAI/kimi-code/pull/1994) [`beeb964`](https://github.com/MoonshotAI/kimi-code/commit/beeb964393c8f9a38c2b1e2273e4415fc434b16d) Thanks [@RealKai42](https://github.com/RealKai42)! - Reduce webview streaming re-render churn: settled assistant messages no longer re-render on every streaming delta, and local images over 10MB are no longer inlined into the webview DOM.
- Updated dependencies [[`ec88d35`](https://github.com/MoonshotAI/kimi-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893), [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50), [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6), [`e458323`](https://github.com/MoonshotAI/kimi-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace)]:
- @moonshot-ai/kimi-code-sdk@0.14.0

View file

@ -80,7 +80,7 @@ stay in the trusted Extension Host.
The runtime constructs the SDK client with:
- `userAgentProduct: "kimi-code-vscode"`
- `productName: "kimi-code-vscode"`
- `version` from `apps/vscode/package.json`
- `uiMode: "vscode"`

View file

@ -3,7 +3,7 @@
"publisher": "moonshot-ai",
"displayName": "Kimi Code",
"description": "Official Kimi Code plugin for VS Code",
"version": "0.6.5",
"version": "0.6.6",
"private": true,
"license": "Apache-2.0",
"type": "module",

View file

@ -297,6 +297,9 @@ function walkSyntax(value, visit) {
function isRuntimeRequire(callee) {
if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name);
if (callee?.type !== 'MemberExpression' || callee.computed === true) return false;
// `this.require(...)` is an ordinary class method call (e.g. a private field
// accessor in bundled sources), never a CommonJS require of a bare specifier.
if (callee.object?.type === 'ThisExpression') return false;
return callee.property?.type === 'Identifier' && callee.property.name === 'require';
}

View file

@ -60,8 +60,9 @@ export class KimiRuntime {
createKimiHarness({
...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
identity: {
userAgentProduct: "kimi-code-vscode",
productName: "kimi-code-vscode",
version: options.version,
platform: "kimi_code_vscode",
},
uiMode: "vscode",
});

View file

@ -0,0 +1,86 @@
/**
* Scenario: App-level view routing after init, across login state transitions.
* Responsibilities: the sign-in screen must stay reachable from every state in
* particular the no-models state (a managed OAuth token exists but config.toml
* has no models, e.g. a first login whose model provisioning failed after the
* device flow already persisted the token), where Reload alone can never change
* the on-disk state and the user would otherwise be stranded.
* Wiring: resolveAppView is pure; the bridge and toast boundaries are mocked away.
* Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/app-init.test.ts
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/services", () => ({
bridge: {},
Events: {},
}));
vi.mock("@/components/ui/sonner", () => ({
toast: { error: vi.fn(), warning: vi.fn() },
}));
import { resolveAppView, type AppStatus } from "../webview-ui/src/hooks/useAppInit";
function resolve(
status: AppStatus,
options: { modelsCount?: number; skippedLogin?: boolean; showLogin?: boolean } = {},
) {
return resolveAppView({
status,
modelsCount: options.modelsCount ?? 0,
skippedLogin: options.skippedLogin ?? false,
showLogin: options.showLogin ?? false,
});
}
describe("resolveAppView", () => {
it("routes a brand-new user (no token, no models) to the login screen", () => {
expect(resolve("not-logged-in")).toEqual({ view: "login" });
});
it("routes a skipped login without models to no-models with a sign-in path", () => {
expect(resolve("not-logged-in", { skippedLogin: true })).toEqual({
view: "status",
status: "no-models",
canGoToLogin: true,
});
});
it("routes a skipped login with models to the main view", () => {
expect(resolve("not-logged-in", { skippedLogin: true, modelsCount: 2 })).toEqual({
view: "main",
});
});
it("keeps a sign-in path in the no-models trap (token without model config)", () => {
// Regression: this state previously rendered "Model setup required" with only
// a Reload button, making the login screen unreachable for affected users.
expect(resolve("no-models")).toEqual({
view: "status",
status: "no-models",
canGoToLogin: true,
});
});
it("routes to the login screen when the user asks for it from any state", () => {
expect(resolve("no-models", { showLogin: true })).toEqual({ view: "login" });
expect(resolve("ready", { showLogin: true, modelsCount: 1 })).toEqual({ view: "login" });
});
it("routes a no-models user who skips again back to no-models with a sign-in path", () => {
expect(resolve("no-models", { skippedLogin: true })).toEqual({
view: "status",
status: "no-models",
canGoToLogin: true,
});
});
it("routes ready to the main view", () => {
expect(resolve("ready", { modelsCount: 1 })).toEqual({ view: "main" });
});
it("routes non-login error statuses to status screens without a sign-in path", () => {
for (const status of ["loading", "no-workspace", "runtime-error"] as const) {
expect(resolve(status)).toEqual({ view: "status", status, canGoToLogin: false });
}
});
});

View file

@ -141,7 +141,7 @@ async function createRuntimeRig(extraAliases: readonly string[] = []): Promise<R
async function createPlainHarness(homeDir: string): Promise<KimiHarness> {
const harness = createKimiHarness({
homeDir,
identity: { userAgentProduct: "kimi-code-cli", version: "test" },
identity: { productName: "kimi-code-cli", version: "test", platform: "kimi_code_cli" },
});
cleanups.push(() => harness.close());
return harness;

View file

@ -46,7 +46,7 @@ async function createReplayRig(): Promise<ReplayRig> {
const provider = await createFakeProviderHarness();
const harness = createKimiHarness({
homeDir,
identity: { userAgentProduct: "kimi-code-vscode", version: "test" },
identity: { productName: "kimi-code-vscode", version: "test", platform: "kimi_code_vscode" },
});
await harness.setConfig({
providers: {

View file

@ -128,6 +128,27 @@ describe('VSIX verifier CLI (package contract and failure details)', () => {
expect(result.stderr).toContain('Bare runtime dependency "left-pad"');
});
it('does not mistake a bundled this.require(...) method call for a runtime dependency', async () => {
const fixture = await makeVsixFixture('darwin-arm64');
await writeFile(
join(fixture, 'extension', 'dist', 'extension.js'),
'class HostEnvironment {\n' +
' require(field) { return this.info[field]; }\n' +
' get osKind() { return this.require("osKind"); }\n' +
'}\n' +
'export function activate() { return new HostEnvironment(); }\n',
);
const result = runNode(verifierScript, [
'--target',
'darwin-arm64',
'--directory',
fixture,
]);
expect(result.status).toBe(0);
});
it('rejects generated session state inside the package', async () => {
const fixture = await makeVsixFixture('win32-arm64');
const stateDir = join(fixture, 'extension', 'runtime', 'profile');

View file

@ -15,5 +15,5 @@
}
},
"include": ["src/**/*", "shared/**/*", "test/**/*"],
"exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts"]
"exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"]
}

View file

@ -10,7 +10,7 @@ import { LoginScreen } from "./components/LoginScreen";
import { Toaster, toast } from "./components/ui/sonner";
import { useChatStore, useSettingsStore } from "./stores";
import { bridge, Events } from "./services";
import { useAppInit } from "./hooks/useAppInit";
import { useAppInit, resolveAppView } from "./hooks/useAppInit";
import { isPreflightError } from "shared/errors";
import type { UIStreamEvent, StreamError, ExtensionConfig } from "shared/types";
import "./styles/index.css";
@ -81,22 +81,34 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
export default function App() {
const { status, errorMessage, modelsCount, refresh } = useAppInit();
const [skippedLogin, setSkippedLogin] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const handleLoginSuccess = useCallback(() => {
refresh();
}, [refresh]);
const handleSkip = useCallback(() => {
setSkippedLogin(true);
}, []);
const handleAuthAction = useCallback(() => {
setShowLogin(false);
setSkippedLogin(false);
refresh();
}, [refresh]);
// 未登录且未跳过
if (status === "not-logged-in" && !skippedLogin) {
const handleSkip = useCallback(() => {
setShowLogin(false);
setSkippedLogin(true);
}, []);
const handleShowLogin = useCallback(() => {
setSkippedLogin(false);
setShowLogin(true);
}, []);
const handleAuthAction = useCallback(() => {
setSkippedLogin(false);
setShowLogin(false);
refresh();
}, [refresh]);
const resolution = resolveAppView({ status, modelsCount, skippedLogin, showLogin });
// 登录界面:未登录且未跳过,或用户从其他界面主动选择登录
if (resolution.view === "login") {
return (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
@ -106,23 +118,17 @@ export default function App() {
);
}
// 跳过登录但没有模型
if (skippedLogin && modelsCount === 0) {
// 错误与设置状态界面no-models 必须保留回到登录界面的入口
if (resolution.view === "status") {
return (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
<ConfigErrorScreen type="no-models" errorMessage={errorMessage} onRefresh={refresh} onBackToLogin={() => setSkippedLogin(false)} />
<Toaster position="top-center" />
</div>
);
}
// 其他错误状态
if (status !== "ready" && status !== "not-logged-in") {
return (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
<ConfigErrorScreen type={status} errorMessage={errorMessage} onRefresh={refresh} />
<ConfigErrorScreen
type={resolution.status}
errorMessage={errorMessage}
onRefresh={refresh}
onBackToLogin={resolution.canGoToLogin ? handleShowLogin : undefined}
/>
<Toaster position="top-center" />
</div>
);

View file

@ -89,7 +89,7 @@ export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) {
<div className="space-y-2">
<div className="inline-flex items-center gap-2 text-blue-500">
<IconLoader2 className="size-5 animate-spin" />
<span className="text-sm font-medium">Waiting for authorization...</span>
<span className="text-sm font-medium">Waiting for authentication...</span>
</div>
<p className="text-xs leading-5 text-muted-foreground text-left">A browser window should open automatically. Complete the sign-in process there.</p>
</div>

View file

@ -5,6 +5,45 @@ import type { ExtensionConfig } from "shared/types";
export type AppStatus = "loading" | "no-workspace" | "runtime-error" | "not-logged-in" | "no-models" | "ready";
export type ConfigErrorStatus = "loading" | "no-workspace" | "runtime-error" | "no-models";
export type AppViewResolution =
| { readonly view: "login" }
| {
readonly view: "status";
readonly status: ConfigErrorStatus;
/** True when the status screen must offer a path to the sign-in screen. */
readonly canGoToLogin: boolean;
}
| { readonly view: "main" };
/**
* Pure view router for App. The `no-models` status (a managed OAuth token
* exists but config.toml has no models e.g. a first login whose model
* provisioning failed after the device flow already persisted the token)
* must always keep a path back to the sign-in screen: Reload alone cannot
* change the on-disk state, so without it the user is stranded and the
* login UI becomes unreachable.
*/
export function resolveAppView(input: {
readonly status: AppStatus;
readonly modelsCount: number;
readonly skippedLogin: boolean;
readonly showLogin: boolean;
}): AppViewResolution {
const { status, modelsCount, skippedLogin, showLogin } = input;
if (showLogin || (status === "not-logged-in" && !skippedLogin)) {
return { view: "login" };
}
if (skippedLogin && modelsCount === 0) {
return { view: "status", status: "no-models", canGoToLogin: true };
}
if (status !== "ready" && status !== "not-logged-in") {
return { view: "status", status, canGoToLogin: status === "no-models" };
}
return { view: "main" };
}
export interface AppInitState {
status: AppStatus;
errorMessage: string | null;

View file

@ -20,5 +20,5 @@
"shared/*": ["../shared/*"]
}
},
"include": ["src", "../test/settings-store.test.ts"]
"include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts"]
}

View file

@ -45,7 +45,7 @@ Beyond the three built-in sub-agents, you can define your own agents as Markdown
### Agent Locations
Kimi Code CLI discovers agent files by scope; more specific scopes take higher priority: **Explicit (`--agent-file`) > Project > Extra > User > Built-in**. When two files define the same `name`, the higher-priority scope wins. Each directory is scanned recursively for `.md` files.
Kimi Code CLI discovers agent files by scope; more specific scopes take higher priority: **Explicit (`--agent-file`) > Project > Extra > User > Plugin > Built-in**. When two files define the same `name`, the higher-priority scope wins. Each directory is scanned recursively for `.md` files.
**User level** (applies to all projects):
- `$KIMI_CODE_HOME/agents/` (default: `~/.kimi-code/agents/`)
@ -63,6 +63,8 @@ The Kimi-specific user agent directory moves with `KIMI_CODE_HOME`, while the ge
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
```
**Plugin level**: directories declared in an enabled plugin's manifest `agents` field (when omitted, the `agents/` directory under the plugin root is picked up automatically); see [Plugin Agents](./plugins.md#plugin-agents). Plugin agents outrank only the built-in agents.
**Built-in agents** are distributed with the CLI and have the lowest priority. A directory-discovered file does not override a same-name built-in Agent unless its frontmatter declares `override: true`. A file loaded through `--agent-file` is treated as explicit launch intent, may override a same-name built-in Agent, outranks every directory scope, and applies to the current launch only. Separately, `$KIMI_CODE_HOME/SYSTEM.md` permanently overrides the default main agent's system prompt (it is not part of agent-file discovery); its precedence interactions are covered in the SYSTEM.md section below.
::: warning Trust model

View file

@ -1,6 +1,6 @@
# Plugins
Plugins package reusable Kimi Code CLI capabilities into installable units — they can add [Agent Skills](./skills.md), automatically load a specified Skill at session start, contribute system-prompt instructions, and declare MCP servers to provide real tool capabilities. They are ideal for sharing workflows with a team, connecting to external services, or installing extensions from the official marketplace.
Plugins package reusable Kimi Code CLI capabilities into installable units — they can add [Agent Skills](./skills.md), custom [agents](./agents.md), automatically load a specified Skill at session start, contribute system-prompt instructions, and declare MCP servers to provide real tool capabilities. They are ideal for sharing workflows with a team, connecting to external services, or installing extensions from the official marketplace.
## Installation and Management
@ -162,6 +162,7 @@ Supported fields:
| `version`, `description`, `keywords`, `author`, `homepage`, `license` | Display metadata |
| `interface` | Fields shown in `/plugins`: `displayName`, `shortDescription`, `longDescription`, `developerName`, `websiteURL` |
| `skills` | One or more `./` paths; must be within the plugin root directory. When omitted, the `SKILL.md` in the root directory is treated as a single Skill root |
| `agents` | One or more `./` paths; must be within the plugin root directory and point to directories containing [agent files](./agents.md#custom-agents). When omitted, the `agents/` directory under the plugin root (if present) is picked up automatically |
| `sessionStart.skill` | Loads the specified plugin Skill into the main Agent when a new or resumed session starts |
| `skillInstructions` | Additional instructions appended whenever a Skill from this plugin is loaded |
| `systemPrompt` | Inline instructions contributed to the agent's system prompt while the plugin is enabled |
@ -271,6 +272,19 @@ my-plugin/
Regardless of how a Skill is loaded (`sessionStart.skill`, `/skill:<name>`, or automatic model invocation), `skillInstructions` appears alongside that plugin's Skill.
## Plugin Agents
A plugin can ship custom agents: declare one or more `./` directories in the manifest's `agents` field (or simply place an `agents/` directory under the plugin root). The agent files inside use the same format as [custom agents](./agents.md#custom-agents) and, while the plugin is enabled, are discovered automatically and can be delegated to as sub-agents by the main Agent.
```text
my-plugin/
kimi.plugin.json
agents/
reviewer.md
```
Plugin agents rank below every other file source: on a name collision, user-level, extra, project-level, and `--agent-file` agents all win over the plugin-provided one, and replacing a built-in agent still requires an explicit `override: true` in the frontmatter. After installing, enabling, disabling, or removing a plugin, the agent list refreshes in a new session (or on `/reload`); on the v2 engine the live session also refreshes after `/plugins reload`.
## MCP Servers in Plugins
When a plugin needs real tool capabilities, it can declare `mcpServers` in its manifest, reusing the [MCP](./mcp.md) schema.

View file

@ -109,7 +109,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest
**`TaskList`** returns the list of background tasks. Optional parameters: `active_only` (defaults to true; lists only running tasks) and `limit` (defaults to 20; range 1100).
**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. Optional `block` (defaults to false) and `timeout` (seconds to wait; defaults to 30; range 03600) parameters allow waiting for the task to complete before returning.
**`TaskOutput`** returns the status and output of a task given its `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved to disk, and the tool also returns an `output_path` with a suggestion to use `Read` for paginated access. The call is always non-blocking — it returns the current snapshot immediately, and task completion is delivered via automatic notification.
**`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state.

View file

@ -6,6 +6,21 @@ outline: 2
This page documents the changes in each Kimi Code CLI release.
## 0.31.0 (2026-07-30)
### Features
- Support Markdown-defined custom agents on agent-core.
- Add the /secondary_model slash command to configure the secondary model used by subagents (experimental; enable it in /experiments first).
- Plugins can contribute custom agents, discovered automatically and available for sub-agent delegation.
- Plugins can contribute system prompt instructions through `systemPrompt` or `systemPromptPath` in `kimi.plugin.json`.
### Bug Fixes
- Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification.
- Fix sessions missing from the session picker when their cached metadata predates the archived flag.
- Fix request headers not being passed correctly on some requests.
## 0.30.0 (2026-07-29)
### Features

View file

@ -45,7 +45,7 @@ Kimi Code CLI 内置三种子 Agent开箱即用分别面向不同任务形
### Agent 目录
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`> 项目 > 额外 > 用户 > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
Kimi Code CLI 按作用域发现 Agent 文件,作用域越具体,优先级越高:**显式(`--agent-file`> 项目 > 额外 > 用户 > Plugin > 内置**。两个文件定义了相同的 `name` 时,高优先级作用域胜出。每个目录都会递归扫描 `.md` 文件。
**用户级**(对所有项目生效):
- `$KIMI_CODE_HOME/agents/`(默认:`~/.kimi-code/agents/`
@ -63,6 +63,8 @@ Kimi 专属的用户 Agent 目录随 `KIMI_CODE_HOME` 移动,通用的 `~/.age
extra_agent_dirs = ["~/team-agents", ".agents/team-agents"]
```
**Plugin 级**:已启用 plugin 在其 manifest 的 `agents` 字段中声明的目录(省略时自动采用 plugin 根下的 `agents/` 目录),见[插件 Agent](./plugins.md#插件-agent)。Plugin Agent 优先级仅高于内置 Agent。
**内置 Agent** 随 CLI 分发,优先级最低。目录中发现的文件不会仅凭同名覆盖内置 Agent如确需替换必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent优先级高于所有目录作用域且仅对本次启动生效。另外`$KIMI_CODE_HOME/SYSTEM.md` 可永久覆盖默认主 Agent 的系统提示词(它不参与 Agent 文件发现),其优先级交互见下文 SYSTEM.md 小节。
::: warning 信任模型

View file

@ -1,6 +1,6 @@
# Plugins
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)、在会话启动时自动加载指定 Skill、提供系统提示词指令也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以添加 [Agent Skills](./skills.md)、自定义 [Agent](./agents.md)、在会话启动时自动加载指定 Skill、提供系统提示词指令也可以声明 MCP servers 来提供真实工具能力。适合把工作流共享给团队、连接外部服务,或从官方 marketplace 安装扩展。
## 安装与管理
@ -162,6 +162,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以
| `version``description``keywords``author``homepage``license` | 展示元数据 |
| `interface` | 在 `/plugins` 中展示的字段:`displayName``shortDescription``longDescription``developerName``websiteURL` |
| `skills` | 一个或多个 `./` 路径,必须位于 plugin 根目录内。省略时根目录的 `SKILL.md` 被当作单个 Skill root |
| `agents` | 一个或多个 `./` 路径,必须位于 plugin 根目录内,指向含有 [Agent 文件](./agents.md#自定义-agent)的目录。省略时根下的 `agents/` 目录(若存在)被自动采用 |
| `sessionStart.skill` | 在新会话或恢复会话开始时,把指定 plugin Skill 加载到主 Agent |
| `skillInstructions` | 每次加载此 plugin 的 Skill 时一并附带的额外说明 |
| `systemPrompt` | plugin 启用期间提供给 Agent 系统提示词的内联指令 |
@ -271,6 +272,19 @@ my-plugin/
无论 Skill 通过哪种方式加载(`sessionStart.skill``/skill:<name>` 或模型自动调用),`skillInstructions` 都会随该 plugin 的 Skill 一起出现。
## 插件 Agent
Plugin 可以携带自定义 Agent在 manifest 的 `agents` 字段里声明一个或多个 `./` 目录(或直接在 plugin 根下放置 `agents/` 目录),其中的 Agent 文件与[自定义 Agent](./agents.md#自定义-agent) 格式相同,会在 plugin 启用期间作为子 Agent 被主 Agent 自动发现和委派。
```text
my-plugin/
kimi.plugin.json
agents/
reviewer.md
```
Plugin Agent 的优先级低于其他文件来源:同名时用户级、额外目录、项目级和 `--agent-file` 的 Agent 都会覆盖 plugin 提供的版本;替换内置 Agent 同样需要在 frontmatter 里显式写 `override: true`。安装、启用、禁用或移除 plugin 后Agent 列表在新会话(或 `/reload`时刷新v2 引擎的当前会话还会在 `/plugins reload` 后刷新。
## Plugin 中的 MCP servers
当 plugin 需要真实工具能力时,可以在 manifest 中声明 `mcpServers`,复用 [MCP](./mcp.md) 的 schema。

View file

@ -109,7 +109,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
**`TaskList`** 返回后台任务列表。可选参数 `active_only`(默认 true仅列出运行中的任务`limit`(默认 20取值范围 1100
**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。可选 `block`(默认 false`timeout`(等待秒数,默认 30取值范围 03600参数可用于等待任务完成后再返回
**`TaskOutput`** 根据 `task_id` 返回任务状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取。该调用始终是非阻塞的——立即返回当前快照,任务完成会通过自动通知送达
**`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。

View file

@ -6,6 +6,21 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.31.02026-07-30
### 新功能
- TUI 支持 Markdown 定义的自定义 Agent。
- 新增 /secondary_model 斜杠命令,用于配置子 Agent 使用的辅助模型(实验性功能,需先在 /experiments 中开启)。
- 插件可贡献自定义 Agent自动发现并可用于子 Agent 委派。
- 插件可贡献系统提示词,通过 `kimi.plugin.json` 中的 `systemPrompt``systemPromptPath` 声明。
### 修复
- 移除 TaskOutput 工具的阻塞式 `block`/`timeout` 等待。
- 修复会话元数据缓存早于 archived 标记时会话选择器缺少会话的问题。
- 修复部分请求未能正确传递请求头的问题。
## 0.30.02026-07-29
### 新功能

View file

@ -1,5 +1,13 @@
# @moonshot-ai/acp-adapter
## 0.3.6
### Patch Changes
- Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa), [`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
- @moonshot-ai/agent-core@0.15.7
- @moonshot-ai/kimi-code-sdk@0.15.0
## 0.3.5
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/acp-adapter",
"version": "0.3.5",
"version": "0.3.6",
"private": true,
"description": "Agent Client Protocol adapter for kimi-code",
"license": "MIT",

View file

@ -1,5 +1,16 @@
# @moonshot-ai/agent-core-v2
## 0.3.0
### Minor Changes
- [#2382](https://github.com/MoonshotAI/kimi-code/pull/2382) [`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa) Thanks [@liruifengv](https://github.com/liruifengv)! - Replace the bootstrap `clientVersion` with a required `clientIdentity` host identity object: the OAuth device-flow endpoints now send the full `X-Msh-*` device headers on every host, telemetry reads the client version from the same source, and the session export manifest gains an optional desktop version field.
### Patch Changes
- Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
- @moonshot-ai/kimi-code-oauth@0.3.0
## 0.2.0
### Minor Changes

View file

@ -23,7 +23,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (Session: 28 keys · Agent: 69 keys)
// Index (Session: 28 keys · Agent: 67 keys)
// Session
// cron.inFlight src/session/cron/sessionCronServiceImpl.ts
// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts
@ -63,8 +63,6 @@
// contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts
// contextSize.lastEmittedTokens src/agent/contextSize/contextSizeService.ts
// externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts
// faultInjection.armed src/agent/faultInjection/faultInjectionService.ts
// faultInjection.fired src/agent/faultInjection/faultInjectionService.ts
// fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts
@ -983,9 +981,6 @@ export interface AgentStateSnapshot {
'contextSize.lastEmittedTokens': number;
// src/agent/externalHooks/externalHooksService.ts
'externalHooks.stopHookContinuationUsed': boolean;
// src/agent/faultInjection/faultInjectionService.ts
'faultInjection.armed': 'request-too-large' | 'image-format' | undefined;
'faultInjection.fired': (/* FaultKind — packages/agent-core-v2/src/agent/faultInjection/faultInjection.ts */ 'request-too-large' | 'image-format')[];
// src/agent/fullCompaction/fullCompactionService.ts
'fullCompaction.activeTurnId': number | undefined;
'fullCompaction.compactionCountInTurn': number;
@ -1013,7 +1008,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2683": undefined;
readonly "__@mediaStripSnapshotBrand@2661": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/agent-core-v2",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"description": "The unified agent engine for Kimi (v2 — DI Scope architecture)",
"license": "MIT",

View file

@ -204,7 +204,6 @@ const DOMAIN_LAYER = new Map([
// the domain to L4 beside the other agent-behaviour tools.
['edit', 4],
['llmRequester', 4],
['faultInjection', 4],
['profile', 4],
['prompt', 4],
// `shellCommand` orchestrates user `!` commands through `toolRegistry` (L3),

View file

@ -1,41 +0,0 @@
/**
* `faultInjection` domain (L4) deterministic provider-failure simulation
* for testing the requester's recovery projections over a live channel.
*
* The turn-loop recovery resends (media-degraded after an HTTP 413 body-size
* rejection, media-stripped after an image-format rejection) are
* deterministic given a provider error, but a real provider cannot be asked
* to produce one on demand. Arming a one-shot fault makes the next LLM
* request attempt raise the chosen error BEFORE the provider is contacted,
* so the recovery path projection rebuild, per-turn stickiness, wire
* records runs end-to-end while the (successful) resend still goes to the
* real provider.
*
* `arm` is refused unless the `fault-injection` experimental flag is enabled
* (see ./flag); `take` is the requester's consumption point and stays inert
* otherwise.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type FaultKind = 'request-too-large' | 'image-format';
export interface FaultInjectionStatus {
readonly armed: FaultKind | undefined;
readonly fired: readonly FaultKind[];
}
export interface IFaultInjectionService {
readonly _serviceBrand: undefined;
arm(kind: FaultKind): void;
status(): FaultInjectionStatus;
clear(): void;
take(): FaultKind | undefined;
}
export const IFaultInjectionService: ServiceIdentifier<IFaultInjectionService> =
createDecorator<IFaultInjectionService>('faultInjectionService');

View file

@ -1,89 +0,0 @@
/**
* `faultInjection` domain (L4) `IFaultInjectionService` implementation.
*
* Agent-scope one-shot latch: `arm` (flag-gated) stores the next fault,
* `take` (the llmRequester's per-attempt consumption point) consumes and
* records it. Both state slots (`armed`, `fired`) are registered into
* `agentState` (`IAgentStateService`) and read/written through it. Bound at
* Agent scope.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/_base/state/stateRegistry';
import { IAgentStateService } from '#/agent/state/agentState';
import { IFlagService } from '#/app/flag/flag';
import { ErrorCodes, Error2 } from '#/errors';
import { FAULT_INJECTION_FLAG_ID } from './flag';
import {
IFaultInjectionService,
type FaultInjectionStatus,
type FaultKind,
} from './faultInjection';
export const faultInjectionArmedKey = defineState<FaultKind | undefined>(
'faultInjection.armed',
() => undefined as FaultKind | undefined,
);
export const faultInjectionFiredKey = defineState<FaultKind[]>('faultInjection.fired', () => []);
export class FaultInjectionService implements IFaultInjectionService {
declare readonly _serviceBrand: undefined;
constructor(
@IFlagService private readonly flags: IFlagService,
@IAgentStateService private readonly states: IAgentStateService,
) {
this.states.register(faultInjectionArmedKey);
this.states.register(faultInjectionFiredKey);
}
private get armed(): FaultKind | undefined {
return this.states.get(faultInjectionArmedKey);
}
private set armed(value: FaultKind | undefined) {
this.states.set(faultInjectionArmedKey, value);
}
private get fired(): FaultKind[] {
return this.states.get(faultInjectionFiredKey);
}
arm(kind: FaultKind): void {
if (!this.flags.enabled(FAULT_INJECTION_FLAG_ID)) {
throw new Error2(
ErrorCodes.REQUEST_INVALID,
'Fault injection is disabled; enable the fault-injection experimental flag ' +
'(KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION=1, the master flag, or the ' +
'[experimental] config section).',
);
}
this.armed = kind;
}
status(): FaultInjectionStatus {
return { armed: this.armed, fired: [...this.fired] };
}
clear(): void {
this.armed = undefined;
this.fired.length = 0;
}
take(): FaultKind | undefined {
const kind = this.armed;
if (kind === undefined) return undefined;
this.armed = undefined;
this.fired.push(kind);
return kind;
}
}
registerScopedService(
LifecycleScope.Agent,
IFaultInjectionService,
FaultInjectionService,
ScopeActivation.OnScopeCreated,
'faultInjection',
);

View file

@ -1,29 +0,0 @@
/**
* `faultInjection` domain (L4) registers the `fault-injection` experimental
* flag into `flag`.
*
* Gates the fault-injection Service's `arm`: deterministic provider-failure
* simulation for exercising the requester's recovery projections over a live
* channel. Off by default; enable via
* `KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION`, the master
* `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section.
* Imported for its side effect (registers the definition) from the package
* barrel.
*/
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
export const FAULT_INJECTION_FLAG_ID = 'fault-injection';
export const FAULT_INJECTION_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION';
export const faultInjectionFlag: FlagDefinitionInput = {
id: FAULT_INJECTION_FLAG_ID,
title: 'Fault injection (LLM request failures)',
description:
'Allow arming a one-shot deterministic provider failure (HTTP 413 body-size or image-format rejection) on the next LLM request, for testing the media-degraded / media-stripped recovery projections over a live channel.',
env: FAULT_INJECTION_FLAG_ENV,
default: false,
surface: 'core',
};
registerFlagDefinition(faultInjectionFlag);

View file

@ -39,10 +39,6 @@ import {
type MediaStripSnapshot,
} from '#/agent/contextProjector/contextProjector';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import {
IFaultInjectionService,
type FaultKind,
} from '#/agent/faultInjection/faultInjection';
import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
@ -184,7 +180,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
@ILogService private readonly log: ILogService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IWireService private readonly wire: IWireService,
@IFaultInjectionService private readonly faultInjection: IFaultInjectionService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentStateService private readonly states: IAgentStateService,
) {
@ -387,11 +382,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.logRequest(logInput);
this.recordRequest(logInput);
const fault = this.faultInjection.take();
if (fault !== undefined) {
throw faultToError(fault);
}
let message: Message | undefined;
let usage = emptyUsage();
let timing: ModelRequestTiming | undefined;
@ -806,12 +796,6 @@ function projectionField(
: undefined;
}
function faultToError(kind: FaultKind): Error {
return kind === 'request-too-large'
? new APIRequestTooLargeError(413, 'Request Entity Too Large (fault injection)')
: new APIStatusError(400, 'unsupported image format: image/avif (fault injection)');
}
function fingerprint(content: string): string {
return createHash('sha256').update(content).digest('hex');
}

View file

@ -189,7 +189,7 @@ const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
const ACTIVE_BACKGROUND_TASK_GUIDANCE = [
'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before.',
'Do not start duplicates. Use TaskOutput to fetch a tasks result, TaskList to list them, and TaskStop to cancel one.',
'Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot, and TaskStop to cancel one — completion arrives via automatic notification.',
].join(' ');
export function isAgentTaskTerminal(status: AgentTaskStatus): boolean {

View file

@ -1,3 +1,3 @@
When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say.
Default to a foreground subagent (omit `run_in_background`) when your next step needs its result — foreground hands the result straight back. Reach for `run_in_background=true` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (with `TaskOutput block=true`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead.
Default to a foreground subagent (omit `run_in_background`) when your next step needs its result — foreground hands the result straight back. Reach for `run_in_background=true` only when you have other work to do while it runs and do not need its result to proceed. Never launch in the background and then immediately wait on it (by polling `TaskOutput`, sleeping, or otherwise): that just blocks the turn for no benefit — run it in the foreground instead.

View file

@ -13,7 +13,7 @@ The dedicated tools render in the per-tool permission UI and keep raw stdout out
**Output:**
The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead.
If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not set `block=true` to wait for a task you just launched, since its completion arrives automatically; reserve `block=true` for when the user explicitly asked you to wait. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands.
If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands.
**Guidelines for safety and security:**
- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call.

View file

@ -385,7 +385,7 @@ export class BashTool implements IBashTool {
if (!output.fullOutputAvailable || output.outputPath === undefined) return result;
const taskOutputHint = this.allowBackground()
? `, or TaskOutput(task_id="${taskId}", block=false)`
? `, or TaskOutput(task_id="${taskId}")`
: '';
const reference =
`\n\n[Full output saved]\n` +

View file

@ -1,13 +1,11 @@
Retrieve a snapshot of a running or completed background task.
Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` to check progress, or to read the output of a task that has already completed.
Use this after `Bash(run_in_background=true)`, `Agent(run_in_background=true)`, or `AskUserQuestion(background=true)` to check progress, or to read the output of a task that has already completed.
Guidelines:
- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives.
- By default this tool is non-blocking and returns a current status/output snapshot — that is the normal way to use it.
- This tool is always non-blocking: it returns the current status/output snapshot immediately and never waits for the task to finish.
- Do not use TaskOutput to wait for a result you need before continuing — if your next step depends on the task's result, run that task in the foreground instead. TaskOutput is for a deliberate progress check you will act on without blocking, not a way to sit and wait for a background task you just launched.
- Use block=true only when the user explicitly asked you to wait for the task. Never block on a task you launched in the current turn — if you need its result right away, it should have been a foreground call.
- If a block=true call returns `retrieval_status: timeout` (the task is still running), do not block on the same task again. Continue with other work or hand back to the user — the completion notification arrives on its own.
- This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log.
- For a terminal task, the metadata also explains why it ended. A shell command that runs to completion reports `status: completed` on a zero exit, or `status: failed` with its non-zero `exit_code` — judge that failure from the `exit_code`, because a plain command failure carries no `stop_reason` and no `terminal_reason`. `terminal_reason` is a categorical label emitted only when the end is not an ordinary exit: `timed_out` when the deadline aborted it, `stopped` when it was explicitly stopped, or `failed` when it errored without producing an exit code; the `stopped` and `failed` cases also carry a human-readable `stop_reason`. A task that finished on its own with a clean exit carries neither `stop_reason` nor `terminal_reason`.
- The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated.

View file

@ -15,21 +15,6 @@ import { type AgentTool } from '#/tool/toolContract';
export const TaskOutputInputSchema = z.object({
task_id: z.string().describe('The background task ID to inspect.'),
block: z
.boolean()
.default(false)
.describe(
'Whether to wait for the task to finish before returning. Discouraged — background tasks notify automatically on completion; use only when the user explicitly asked you to wait.',
)
.optional(),
timeout: z
.number()
.int()
.min(0)
.max(3600)
.default(30)
.describe('Maximum number of seconds to wait when block=true.')
.optional(),
});
export type TaskOutputInput = z.infer<typeof TaskOutputInputSchema>;

View file

@ -39,12 +39,8 @@ const OUTPUT_PREVIEW_BYTES = 32 * 1024;
const PAGING_HINT_LINES = 300;
function retrievalStatus(
status: AgentTaskStatus,
block: boolean | undefined,
): 'success' | 'timeout' | 'not_ready' {
if (TERMINAL_STATUSES.has(status)) return 'success';
return block ? 'timeout' : 'not_ready';
function retrievalStatus(status: AgentTaskStatus): 'success' | 'not_ready' {
return TERMINAL_STATUSES.has(status) ? 'success' : 'not_ready';
}
function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined {
@ -85,23 +81,11 @@ export class TaskOutputTool implements ITaskOutputTool {
description: `Reading output of task ${args.task_id}`,
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id),
execute: ({ signal }) => this.execute(args, signal),
execute: () => this.execute(args),
};
}
private async execute(
args: TaskOutputInput,
signal: AbortSignal,
): Promise<ExecutableToolResult> {
const info = this.tasks.getTask(args.task_id);
if (!info) {
return { isError: true, output: `Task not found: ${args.task_id}` };
}
if (args.block && !TERMINAL_STATUSES.has(info.status)) {
await this.tasks.wait(args.task_id, (args.timeout ?? 30) * 1000, signal);
}
private async execute(args: TaskOutputInput): Promise<ExecutableToolResult> {
const current = this.tasks.getTask(args.task_id);
if (!current) {
return { isError: true, output: `Task not found: ${args.task_id}` };
@ -111,7 +95,7 @@ export class TaskOutputTool implements ITaskOutputTool {
const lines = [
formatPlainObject({
retrievalStatus: retrievalStatus(current.status, args.block),
retrievalStatus: retrievalStatus(current.status),
...current,
outputPath: output.outputPath,
terminalReason: terminalReason(current),
@ -122,10 +106,6 @@ export class TaskOutputTool implements ITaskOutputTool {
fullOutputTool:
output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined,
fullOutputHint: fullOutputHint(output),
nextStep:
args.block === true && !TERMINAL_STATUSES.has(current.status)
? 'The task is still running after waiting. Do not block on it again — continue with other work or hand back to the user; you will be notified automatically when it completes.'
: undefined,
}),
'',
];

View file

@ -7,9 +7,9 @@
* collisions). Mirrors `skillCatalog/skillSource`, with one deliberate
* deviation: `explicit` outranks every other source (in the skill system it
* aliases `user`) because `--agent-file` is a one-shot command-line intent that
* must always win. Concrete sources (user at App scope; project / extra /
* explicit at Session scope) each bind their own DI token extending this
* contract.
* must always win. Concrete sources (user at App scope; plugin / project /
* extra / explicit at Session scope) each bind their own DI token extending
* this contract.
*
* A source may mark `load()` failures as `fatal`: the Session catalog lets
* them propagate into `ready` so awaiters see the error (`explicit` does
@ -38,6 +38,7 @@ export interface AgentProfileContribution {
}
export const AGENT_PROFILE_SOURCE_PRIORITY = {
plugin: 5,
user: 10,
extra: 20,
project: 30,

View file

@ -9,7 +9,7 @@
import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog';
export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit';
export type AgentFileSource = 'plugin' | 'project' | 'user' | 'extra' | 'explicit';
export interface AgentFileRoot {
readonly path: string;

View file

@ -11,6 +11,7 @@
*/
import type {
AuthManagedUserInfoResult,
AuthManagedUsageResult,
BearerTokenProvider,
KimiOAuthLoginOptions,
@ -47,6 +48,7 @@ export interface IOAuthService {
status(provider?: string): Promise<AuthStatus>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
getManagedUsage(provider?: string): Promise<AuthManagedUsageResult>;
getManagedUserInfo(provider?: string): Promise<AuthManagedUserInfoResult>;
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined;
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise<string | undefined>;
}
@ -68,6 +70,10 @@ export interface IOAuthToolkit {
providerName?: string,
options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string },
): Promise<AuthManagedUsageResult>;
getManagedUserInfo(
providerName?: string,
options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string },
): Promise<AuthManagedUserInfoResult>;
}
export const IOAuthToolkit: ServiceIdentifier<IOAuthToolkit> =

View file

@ -27,6 +27,7 @@ import {
resolveKimiCodeLoginAuth,
resolveKimiCodeOAuthRef,
resolveKimiCodeRuntimeAuth,
type AuthManagedUserInfoResult,
type AuthManagedUsageResult,
type BearerTokenProvider,
type DeviceAuthorization,
@ -279,6 +280,18 @@ export class OAuthService extends Disposable implements IOAuthService {
});
}
getManagedUserInfo(provider = KIMI_CODE_PROVIDER_NAME): Promise<AuthManagedUserInfoResult> {
const configured = this.providerService.get(provider);
const auth = resolveKimiCodeRuntimeAuth({
configuredBaseUrl: configured?.baseUrl,
configuredOAuthRef: configured?.oauth,
});
return this.toolkit.getManagedUserInfo(provider, {
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
});
}
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse> {
const run = this.refreshChain.then(() => this.doRefreshOAuthProviderModels());
this.refreshChain = run.then(
@ -864,7 +877,7 @@ function managedModel(
class OAuthToolkitService extends KimiOAuthToolkit implements IOAuthToolkit {
declare readonly _serviceBrand: undefined;
constructor(@IBootstrapService bootstrap: IBootstrapService) {
super({ homeDir: bootstrap.homeDir });
super({ homeDir: bootstrap.homeDir, identity: bootstrap.clientIdentity });
}
}

View file

@ -4,6 +4,8 @@
* Request/response shapes of the v1 `/oauth/*` endpoints plus the managed
* OAuth provider model-refresh response, defined as zod schemas so the
* transports validate against the same contract the `IOAuthService` returns.
* New endpoints use the camelCase domain contract owned by the oauth
* package (re-exported below); legacy snake_case schemas stay local.
*/
import { z } from 'zod';
@ -158,3 +160,15 @@ export const managedUsageResultSchema = z.discriminatedUnion('kind', [
managedUsageErrorSchema,
]);
export type ManagedUsageResult = z.infer<typeof managedUsageResultSchema>;
// ---------------------------------------------------------------------------
// Managed-account profile (`GET /v1/oauth/userinfo`) — camelCase domain
// contract owned by `@moonshot-ai/kimi-code-oauth` (its zod schemas are the
// single source of truth); re-exported here so transports keep one import
// path. Legacy snake_case endpoints keep their local schemas above.
// ---------------------------------------------------------------------------
export {
managedUserInfoResultSchema,
type ManagedUserInfoResult,
} from '@moonshot-ai/kimi-code-oauth';

View file

@ -3,7 +3,7 @@
*
* Defines the `IBootstrapService`, the snapshot of the world the process runs
* in, resolved once at startup and frozen for the process: observed host facts
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientVersion`) and the
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`) and the
* app path layout (`homeDir`, `configPath`, ). `resolveBootstrapOptions` is
* the single place that reads `process.env` / `os.homedir()` / invocation
* input to resolve the snapshot; everything downstream reads from
@ -18,6 +18,8 @@ import { homedir } from 'node:os';
import { join } from 'pathe';
import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
@ -36,7 +38,7 @@ export interface IBootstrapOptions {
readonly arch: string;
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly clientVersion: string;
readonly clientIdentity: KimiHostIdentity;
}
export const IBootstrapOptions: ServiceIdentifier<IBootstrapOptions> =
@ -61,7 +63,7 @@ export interface IBootstrapService {
readonly osHomeDir: string;
readonly homeDir: string;
readonly configPath: string;
readonly clientVersion: string;
readonly clientIdentity: KimiHostIdentity;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
@ -87,10 +89,12 @@ export interface BootstrapInput {
readonly platform?: NodeJS.Platform;
readonly arch?: string;
readonly cwd?: string;
readonly clientVersion?: string;
/** Required: every process names its host. There is deliberately no default
a fabricated identity would silently misreport the host upstream. */
readonly clientIdentity: KimiHostIdentity;
}
export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapOptions {
export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions {
const env = input.env ?? process.env;
const osHomeDir = input.osHomeDir ?? homedir();
const homeDir = resolveKimiHome(input.homeDir, env, osHomeDir);
@ -103,11 +107,11 @@ export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapO
arch: input.arch ?? process.arch,
cwd: input.cwd ?? process.cwd(),
env,
clientVersion: input.clientVersion ?? 'unknown',
clientIdentity: input.clientIdentity,
};
}
export function bootstrapSeed(input: BootstrapInput = {}): ScopeSeed {
export function bootstrapSeed(input: BootstrapInput): ScopeSeed {
return [[IBootstrapOptions as ServiceIdentifier<unknown>, resolveBootstrapOptions(input)]];
}
@ -115,7 +119,7 @@ export interface BootstrapResult {
readonly app: Scope;
}
export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []): BootstrapResult {
export function bootstrap(input: BootstrapInput, extraSeeds: ScopeSeed = []): BootstrapResult {
const options = resolveBootstrapOptions(input);
const app = createAppScope({
extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds],

View file

@ -15,6 +15,8 @@
import { basename, join, relative } from 'pathe';
import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import {
@ -32,7 +34,7 @@ export class BootstrapService implements IBootstrapService {
readonly osHomeDir: string;
readonly homeDir: string;
readonly configPath: string;
readonly clientVersion: string;
readonly clientIdentity: KimiHostIdentity;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
@ -51,7 +53,7 @@ export class BootstrapService implements IBootstrapService {
this.env = options.env;
this.homeDir = options.homeDir;
this.configPath = options.configPath;
this.clientVersion = options.clientVersion;
this.clientIdentity = options.clientIdentity;
this.sessionsDir = join(options.homeDir, 'sessions');
this.blobsDir = join(options.homeDir, 'blobs');
this.storeDir = join(options.homeDir, 'store');

View file

@ -2,7 +2,7 @@
* `hostIdentity` domain (L3) runtime identity of the embedding host.
*
* Holds process-level overrides the host product (CLI, desktop, ) injects at
* the composition root: `productName` fills the `${product_name}` slot in the
* the composition root: `displayName` fills the `${product_name}` slot in the
* base system-prompt template, `replyStyleGuide` replaces the
* `${reply_style_guide}` block (the CLI default describes Markdown rendering
* in a terminal). Composition roots set them through {@link hostIdentitySeed};
@ -13,8 +13,8 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope';
export interface HostIdentityOverrides {
readonly productName?: string;
export interface PromptIdentityOverrides {
readonly displayName?: string;
readonly replyStyleGuide?: string;
}
@ -36,13 +36,13 @@ export class HostIdentity implements IHostIdentity {
) {}
}
export function hostIdentitySeed(overrides: HostIdentityOverrides | undefined): ScopeSeed {
export function hostIdentitySeed(overrides: PromptIdentityOverrides | undefined): ScopeSeed {
if (overrides === undefined) return [];
if (overrides.productName === undefined && overrides.replyStyleGuide === undefined) return [];
if (overrides.displayName === undefined && overrides.replyStyleGuide === undefined) return [];
return [
[
IHostIdentity as ServiceIdentifier<unknown>,
new HostIdentity(overrides.productName, overrides.replyStyleGuide),
new HostIdentity(overrides.displayName, overrides.replyStyleGuide),
],
];
}

View file

@ -12,6 +12,7 @@ import path from 'node:path';
import { Error2, PluginErrors } from '#/errors';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery';
import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery';
import type { SkillRoot } from '#/app/skillCatalog/types';
@ -313,6 +314,17 @@ export class PluginManager {
return roots;
}
pluginAgentRoots(): readonly AgentFileRoot[] {
const roots: AgentFileRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.agents ?? []) {
roots.push({ path: dir, source: 'plugin' });
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {

View file

@ -97,7 +97,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
@ -105,6 +105,14 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}
}
let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics);
if (raw['agents'] === undefined) {
const agentsDir = path.join(pluginRoot, 'agents');
if (await isDir(agentsDir)) {
agents = [agentsDir];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
@ -121,6 +129,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
@ -146,8 +155,9 @@ function recordUnsupportedRuntimeFields(
}
}
async function resolveSkillsField(
async function resolveDirListField(
pluginRoot: string,
field: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
@ -158,7 +168,7 @@ async function resolveSkillsField(
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` });
return [];
}
@ -167,7 +177,7 @@ async function resolveSkillsField(
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
message: `"skills" path must start with "./" (got "${entry}")`,
message: `"${field}" path must start with "./" (got "${entry}")`,
});
continue;
}
@ -182,14 +192,14 @@ async function resolveSkillsField(
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
message: `"${field}" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
message: `"skills" path is not a directory (${entry})`,
message: `"${field}" path is not a directory (${entry})`,
});
continue;
}

View file

@ -11,6 +11,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
import type { Event } from '#/_base/event';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { SkillRoot } from '#/app/skillCatalog/types';
import type {
@ -59,6 +60,7 @@ export interface IPluginService {
listPluginCommands(): Promise<readonly PluginCommandDef[]>;
checkUpdates(): Promise<readonly PluginUpdateStatus[]>;
pluginSkillRoots(): Promise<readonly SkillRoot[]>;
pluginAgentRoots(): Promise<readonly AgentFileRoot[]>;
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]>;
enabledSystemPrompts(): Promise<readonly EnabledPluginSystemPrompt[]>;
enabledMcpServers(): Promise<Record<string, McpServerConfig>>;

View file

@ -21,6 +21,7 @@ import { IProviderService } from '#/kosong/provider/provider';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { SkillRoot } from '#/app/skillCatalog/types';
import { PluginManager } from './manager';
@ -158,6 +159,10 @@ export class PluginService extends Disposable implements IPluginService {
return this.runConsumptionRead([], async () => this.manager.pluginSkillRoots());
}
pluginAgentRoots(): Promise<readonly AgentFileRoot[]> {
return this.runConsumptionRead([], async () => this.manager.pluginAgentRoots());
}
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]> {
return this.runConsumptionRead([], async () => this.manager.enabledSessionStarts());
}

View file

@ -34,6 +34,7 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[];
readonly agents?: readonly string[];
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly hooks?: readonly HookDefConfig[];

View file

@ -29,6 +29,7 @@ export function buildExportManifest(args: {
readonly sessionLogPath?: string | undefined;
readonly globalLogPath?: string | undefined;
readonly webLogPath?: string;
readonly desktopVersion?: string;
readonly installSource?: string | undefined;
readonly shellEnv?: ShellEnvironment | undefined;
}): ExportSessionManifest {
@ -52,6 +53,7 @@ export function buildExportManifest(args: {
sessionLogPath: args.sessionLogPath,
globalLogPath: args.globalLogPath,
webLogPath: args.webLogPath,
desktopVersion: args.desktopVersion,
installSource: args.installSource,
shellEnv: args.shellEnv,
};

View file

@ -23,6 +23,7 @@ export interface ExportSessionPayload {
readonly includeGlobalLog?: boolean | undefined;
readonly includeDesktopLog?: boolean;
readonly version: string;
readonly desktopVersion?: string;
readonly installSource?: string | undefined;
readonly shellEnv?: ShellEnvironment | undefined;
}
@ -42,6 +43,7 @@ export interface ExportSessionManifest {
readonly globalLogPath?: string | undefined;
readonly desktopLogPath?: string;
readonly webLogPath?: string;
readonly desktopVersion?: string;
readonly installSource?: string | undefined;
readonly shellEnv?: ShellEnvironment | undefined;
}

View file

@ -215,6 +215,7 @@ export async function exportSessionDirectory(input: {
sessionScan,
sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL,
webLogPath: bundledWebLog ? WEB_LOG_REL : undefined,
desktopVersion: input.request.desktopVersion,
installSource: input.request.installSource,
shellEnv: input.request.shellEnv,
});

View file

@ -93,6 +93,26 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined):
);
}
/**
* Runtime shape check for read-model cache hits. The query store persists
* caller-provided JSON with no schema enforcement (and entries mirrored before
* a fix may predate required fields e.g. `archived` written as `undefined`
* and dropped by JSON), so a cached value is only trusted when it carries the
* fields the session-summary contract requires; anything else is treated as a
* cold miss and rebuilt from disk.
*/
function isSessionSummaryShape(value: unknown): value is SessionSummary {
if (value === null || typeof value !== 'object') return false;
const summary = value as Record<string, unknown>;
return (
typeof summary['id'] === 'string' &&
typeof summary['workspaceId'] === 'string' &&
typeof summary['createdAt'] === 'number' &&
typeof summary['updatedAt'] === 'number' &&
typeof summary['archived'] === 'boolean'
);
}
export class FileSessionIndex implements ISessionIndex {
declare readonly _serviceBrand: undefined;
@ -173,8 +193,8 @@ export class FileSessionIndex implements ISessionIndex {
}
private async getFromReadModel(id: string): Promise<SessionSummary | undefined> {
const cached = await this.queryStore.get<SessionSummary>(SESSION_COLLECTION, id);
if (cached !== undefined) return cached;
const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, id);
if (isSessionSummaryShape(cached)) return cached;
for (const workspaceId of await this.listWorkspaceIds()) {
if (!(await this.hasSession(workspaceId, id))) continue;
return this.getCachedSummary(workspaceId, id);
@ -217,10 +237,11 @@ export class FileSessionIndex implements ISessionIndex {
workspaceId: string,
sessionId: string,
): Promise<SessionSummary | undefined> {
const cached = await this.queryStore.get<SessionSummary>(SESSION_COLLECTION, sessionId);
if (cached !== undefined) return cached;
const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId);
if (isSessionSummaryShape(cached)) return cached;
const summary = await this.readSummary(workspaceId, sessionId);
if (summary !== undefined) {
// Also overwrites a cache entry that failed the shape check above.
await this.queryStore.put(SESSION_COLLECTION, sessionId, summary);
}
return summary;

View file

@ -3,7 +3,7 @@
* batches events, drops non-primitive properties, redacts PII from string
* values, enriches events with common context, and posts them to the
* telemetry endpoint through `CloudTransport`, which persists failed events
* through the `storage` byte layer. Reads host facts (`clientVersion`, env,
* through the `storage` byte layer. Reads host facts (`clientIdentity`, env,
* platform/arch) from `IBootstrapService`; `createCloudAppender` assembles
* one from a `ServicesAccessor` so hosts only supply identity facts.
* App-scoped; independent of `@moonshot-ai/kimi-telemetry`.
@ -186,8 +186,8 @@ function buildContext(options: CloudAppenderOptions): CloudContext {
const { bootstrap } = options;
const context: CloudContext = {
app_name: options.appName,
client_version: bootstrap.clientVersion,
version: bootstrap.clientVersion,
client_version: bootstrap.clientIdentity.version,
version: bootstrap.clientIdentity.version,
core_version: resolveCoreVersion(),
runtime: 'node',
platform: bootstrap.platform,

View file

@ -212,6 +212,7 @@ export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource';
export * from '#/session/sessionSkillCatalog/pluginSkillSource';
export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService';
export * from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource';
export * from '#/session/sessionAgentProfileCatalog/projectFileAgentSource';
export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource';
export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource';
@ -261,7 +262,6 @@ export * from '#/agent/usage/usageService';
export * from '#/agent/toolDedupe/toolDedupe';
export * from '#/agent/toolDedupe/toolDedupeService';
import '#/agent/toolSelect/flag';
import '#/agent/faultInjection/flag';
export * from '#/agent/tools/select-tools/select-tools';
import '#/agent/tools/select-tools/selectToolsTool';
export * from '#/agent/toolSelect/dynamicTools';
@ -484,8 +484,6 @@ export * from '#/agent/fullCompaction/compactionOps';
export * from '#/agent/fullCompaction/types';
export * from '#/agent/llmRequester/llmRequester';
export * from '#/agent/llmRequester/llmRequesterService';
export * from '#/agent/faultInjection/faultInjection';
export * from '#/agent/faultInjection/faultInjectionService';
export * from '#/agent/llmRequester/llmRequestOps';
export * from '#/_base/utils/retry';
import '#/agent/loop/configSection';

View file

@ -0,0 +1,74 @@
/**
* `sessionAgentProfileCatalog` domain (L3) plugin `IAgentProfileSource`
* producer.
*
* Discovers agent profiles contributed by enabled plugins (roots from
* `plugin.pluginAgentRoots()`), contributing them at priority 5 (above
* builtin, below user / extra / project / explicit, so every other file
* source wins name collisions). `${base_prompt}` is backed by the user
* source's effective default profile. Re-emits `plugin.onDidReload` as
* `onDidChange` so the catalog re-pulls plugin agents when plugins reload;
* install / enable / remove mutations deliberately do not refresh the
* session catalog those take effect on the next explicit reload. Bound at
* Session scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { discoverAgentFiles } from '#/app/agentFileCatalog/agentFileDiscovery';
import {
AGENT_PROFILE_SOURCE_PRIORITY,
profilesFromDiscovery,
type AgentProfileContribution,
type IAgentProfileSource,
} from '#/app/agentFileCatalog/agentProfileSource';
import { IUserFileAgentSource } from '#/app/agentFileCatalog/userFileAgentSource';
import { IPluginService } from '#/app/plugin/plugin';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
export interface IPluginAgentProfileSource extends IAgentProfileSource {
readonly _serviceBrand: undefined;
}
export const IPluginAgentProfileSource: ServiceIdentifier<IPluginAgentProfileSource> =
createDecorator<IPluginAgentProfileSource>('pluginAgentProfileSource');
export class PluginAgentProfileSource implements IPluginAgentProfileSource {
declare readonly _serviceBrand: undefined;
readonly id = 'plugin';
readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.plugin;
readonly onDidChange: Event<void> = (listener, thisArg, disposables) =>
this.plugins.onDidReload(
() => listener.call(thisArg, undefined as void),
undefined,
disposables,
);
constructor(
@IPluginService private readonly plugins: IPluginService,
@IHostFileSystem private readonly fs: IHostFileSystem,
@ILogService private readonly log: ILogService,
@IUserFileAgentSource private readonly user: IUserFileAgentSource,
) {}
async load(): Promise<AgentProfileContribution> {
const roots = await this.plugins.pluginAgentRoots();
return profilesFromDiscovery(
await discoverAgentFiles(this.fs, roots, (message) => {
this.log.warn(message);
}),
(context) => this.user.getDefaultProfile().systemPrompt(context),
);
}
}
registerScopedService(
LifecycleScope.Session,
IPluginAgentProfileSource,
PluginAgentProfileSource,
ScopeActivation.OnScopeCreated,
'sessionAgentProfileCatalog',
);

View file

@ -3,8 +3,9 @@
* implementation.
*
* Merges the builtin (code-contribution) App catalog with the file-backed
* sources (user / extra / project / explicit) by priority, requiring an
* explicit opt-in before a file replaces a same-name builtin, and serializing
* sources (plugin / user / extra / project / explicit) by priority, requiring
* an explicit opt-in before a file replaces a same-name builtin, and
* serializing
* refreshes per source the same way `sessionSkillCatalog` does. The merged
* view always contains the builtin profiles (seeded at construction); file
* profiles appear once `ready` resolves. A rejecting `fatal` source (an
@ -43,6 +44,7 @@ import { ISessionStateService } from '#/session/state/sessionState';
import { IExplicitFileAgentSource } from './explicitFileAgentSource';
import { IExtraFileAgentSource } from './extraFileAgentSource';
import { IPluginAgentProfileSource } from './pluginAgentProfileSource';
import { IProjectFileAgentSource } from './projectFileAgentSource';
import { ISessionAgentProfileCatalog } from './sessionAgentProfileCatalog';
@ -69,6 +71,7 @@ export class SessionAgentProfileCatalogService
constructor(
@ISessionStateService private readonly states: ISessionStateService,
@IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService,
@IPluginAgentProfileSource plugin: IPluginAgentProfileSource,
@IUserFileAgentSource user: IUserFileAgentSource,
@IExtraFileAgentSource extra: IExtraFileAgentSource,
@IProjectFileAgentSource project: IProjectFileAgentSource,
@ -78,7 +81,7 @@ export class SessionAgentProfileCatalogService
super();
this.states.register(agentProfileCatalogContributionsKey);
this.states.register(agentProfileCatalogMergedKey);
this.sources = [user, extra, project, explicit].toSorted(
this.sources = [plugin, user, extra, project, explicit].toSorted(
(a, b) => a.priority - b.priority,
);
for (const s of this.sources) {

View file

@ -143,7 +143,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
lastPrompt: this.data.lastPrompt,
createdAt: this.data.createdAt,
updatedAt: this.data.updatedAt,
archived: this.data.archived,
// `data.archived` stays undefined for sessions whose state.json
// predates the field; the read-model contract requires a boolean
// (`readSummary` normalizes the same way), so an undefined here would
// poison the cache entry and fail contract validation on reads.
archived: this.data.archived === true,
custom: this.data.custom,
});
} catch (error) {

View file

@ -24,8 +24,6 @@ import {
type MediaStripSnapshot,
} from '#/agent/contextProjector/contextProjector';
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
import { IFaultInjectionService } from '#/agent/faultInjection/faultInjection';
import { FaultInjectionService } from '#/agent/faultInjection/faultInjectionService';
import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService';
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
@ -38,7 +36,6 @@ import { IAgentVideoResolverService } from '#/agent/media/videoResolver';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IConfigService } from '#/app/config/config';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { IFlagService } from '#/app/flag/flag';
import {
APIConnectionError,
APIEmptyResponseError,
@ -143,7 +140,6 @@ function createService(
>)
| undefined,
options: {
readonly flagEnabled?: boolean;
readonly thinkingLevel?: ThinkingEffort;
} = {},
) {
@ -187,7 +183,6 @@ function createService(
shapeTools: (entries) => entries,
shapeHistory: (messages) => messages,
};
const flagEnabled = options.flagEnabled ?? true;
const testSnapshot = Object.freeze({}) as MediaStripSnapshot;
const events: DomainEvent[] = [];
const eventBus: IEventBus = {
@ -212,7 +207,6 @@ function createService(
...projector,
});
}
ix.stub(IFlagService, { enabled: () => flagEnabled });
ix.stub(IAgentContextSizeService, contextSize);
ix.stub(IAgentToolRegistryService, tools);
ix.stub(IAgentProfileService, profile);
@ -234,13 +228,11 @@ function createService(
log: recordingWireLog(records),
eventBus,
});
ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService));
ix.set(IAgentStateService, new AgentStateService());
ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService));
return {
service: ix.get(IAgentLLMRequesterService),
faultInjection: ix.get(IFaultInjectionService),
wire: ix.get(IWireService),
records,
events,
@ -623,71 +615,6 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
});
});
describe('AgentLLMRequesterService fault injection (experimental)', () => {
it('raises an armed request-too-large fault before the provider and recovers via the degraded resend', async () => {
const calls = { value: 0 };
let projectCalls = 0;
let degradedCalls = 0;
const { service, faultInjection } = createService(createRequester(calls, null), {
project: (messages: readonly ContextMessage[]) => {
projectCalls += 1;
return messages;
},
projectStrict: (messages: readonly ContextMessage[]) => messages,
projectMediaDegraded: (messages: readonly ContextMessage[]) => {
degradedCalls += 1;
return messages;
},
});
faultInjection.arm('request-too-large');
expect(faultInjection.status().armed).toBe('request-too-large');
const result = await service.request({ source: { type: 'turn', turnId: 1, step: 1 } });
expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(calls.value).toBe(1);
expect(projectCalls).toBe(1);
expect(degradedCalls).toBe(1);
expect(faultInjection.status()).toEqual({
armed: undefined,
fired: ['request-too-large'],
});
});
it('raises an armed image-format fault and recovers via the stripped resend, one-shot only', async () => {
const calls = { value: 0 };
let strippedCalls = 0;
const { service, faultInjection } = createService(createRequester(calls, null), {
project: (messages: readonly ContextMessage[]) => messages,
projectStrict: (messages: readonly ContextMessage[]) => messages,
projectMediaStripped: (messages: readonly ContextMessage[]) => {
strippedCalls += 1;
return messages;
},
});
faultInjection.arm('image-format');
await service.request({ source: { type: 'turn', turnId: 1, step: 1 } });
expect(strippedCalls).toBe(1);
expect(faultInjection.status().fired).toEqual(['image-format']);
const result = await service.request({ source: { type: 'turn', turnId: 2, step: 1 } });
expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(faultInjection.status().fired).toEqual(['image-format']);
});
it('refuses to arm when the fault-injection flag is disabled', () => {
const { faultInjection } = createService(createRequester({ value: 0 }, null), {
project: (messages: readonly ContextMessage[]) => messages,
projectStrict: (messages: readonly ContextMessage[]) => messages,
}, { flagEnabled: false });
expect(() => faultInjection.arm('request-too-large')).toThrow(/disabled/);
expect(faultInjection.status()).toEqual({ armed: undefined, fired: [] });
});
});
describe('AgentLLMRequesterService trace id', () => {
const passthroughProjector = {
project: (messages: readonly ContextMessage[]) => messages,

File diff suppressed because one or more lines are too long

View file

@ -59,6 +59,7 @@ function pluginServiceStub(options: PluginServiceStubOptions): IPluginService {
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => [],
pluginAgentRoots: async () => [],
enabledSessionStarts: async () => options.sessionStarts,
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),

View file

@ -669,7 +669,7 @@ describe('AgentTaskService', () => {
const reminder = await backgroundTaskReminder();
expect(reminder).toContain('The conversation was compacted');
expect(reminder).toContain(
'gone — but the tasks are still running from before. Do not start duplicates. Use TaskOutput to fetch a tasks result',
'gone — but the tasks are still running from before. Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot',
);
expect(reminder).toContain('active_background_tasks: 1');
expect(reminder).toContain(taskId);

View file

@ -4,7 +4,6 @@
import { describe, expect, it, vi } from 'vitest';
import { abortable, userCancellationReason } from '#/_base/utils/abort';
import type {
AgentTask,
AgentTaskInfo,
@ -23,6 +22,7 @@ import { TaskOutputTool } from '#/agent/tools/task/task-output/taskOutputTool';
import { TaskStopInputSchema } from '#/agent/tools/task/task-stop/task-stop';
import { TaskStopTool } from '#/agent/tools/task/task-stop/taskStopTool';
import type { ITaskHandle } from '#/app/task/task';
import { compileToolArgsValidator, validateToolArgs } from '#/tool/args-validator';
import type { ProcessTaskInfo } from '#/agent/tools/os/bash/process-task';
import type { SubagentTaskInfo } from '#/agent/tools/agent/subagent-task';
import { TaskListTool as V1TaskListTool } from '../../../../../agent-core/src/tools/background/task-list';
@ -113,10 +113,6 @@ function outputSnapshot(
interface FakeTaskEntry {
info: AgentTaskInfo;
output: AgentTaskOutputSnapshot;
wait?: (
timeoutMs: number | undefined,
signal: AbortSignal | undefined,
) => Promise<void>;
}
class FakeTaskService implements IAgentTaskService {
@ -131,12 +127,8 @@ class FakeTaskService implements IAgentTaskService {
add(
info: AgentTaskInfo,
output: AgentTaskOutputSnapshot = outputSnapshot(),
wait?: (
timeoutMs: number | undefined,
signal: AbortSignal | undefined,
) => Promise<void>,
): string {
this.entries.set(info.taskId, { info, output, wait });
this.entries.set(info.taskId, { info, output });
return info.taskId;
}
@ -232,12 +224,10 @@ class FakeTaskService implements IAgentTaskService {
async wait(
taskId: string,
timeoutMs?: number,
signal?: AbortSignal,
_signal?: AbortSignal,
): Promise<AgentTaskInfo | undefined> {
this.waitCalls.push({ taskId, timeoutMs });
const entry = this.entries.get(taskId);
await entry?.wait?.(timeoutMs, signal);
return entry?.info;
return this.entries.get(taskId)?.info;
}
async waitForForegroundRelease(
@ -392,22 +382,16 @@ describe('TaskOutputTool', () => {
expect(tool.name).toBe('TaskOutput');
expect(TaskOutputInputSchema.safeParse({ task_id: 'bash-1' }).success).toBe(true);
expect(
TaskOutputInputSchema.safeParse({ task_id: 'bash-1', block: true, timeout: 0 }).success,
).toBe(true);
expect(
TaskOutputInputSchema.safeParse({ task_id: 'bash-1', timeout: 3601 }).success,
).toBe(false);
expect(tool.parameters).toMatchObject({
type: 'object',
additionalProperties: false,
required: ['task_id'],
properties: {
task_id: { type: 'string' },
block: { type: 'boolean' },
timeout: { type: 'integer' },
},
});
expect(JSON.stringify(tool.parameters)).not.toContain('"block"');
expect(JSON.stringify(tool.parameters)).not.toContain('"timeout"');
});
it('returns error for unknown task', async () => {
@ -464,7 +448,7 @@ describe('TaskOutputTool', () => {
const result = await executeTool(
new TaskOutputTool(tasks),
context('task_output_persisted', { task_id: taskId, block: true }),
context('task_output_persisted', { task_id: taskId }),
);
const output = outputString(result);
@ -511,54 +495,13 @@ describe('TaskOutputTool', () => {
expect(tasks.waitCalls).toEqual([]);
});
it('returns timeout for block=true when a running task does not finish', async () => {
const tasks = new FakeTaskService();
const taskId = tasks.add(processTask({ taskId: 'bash-running4' }));
it('rejects stale block/timeout args at the validator instead of waiting', () => {
const validator = compileToolArgsValidator(new TaskOutputTool(new FakeTaskService()).parameters);
const result = await executeTool(
new TaskOutputTool(tasks),
context('task_output_timeout', { task_id: taskId, block: true, timeout: 1 }),
);
const output = outputString(result);
expect(result.isError ?? false).toBe(false);
expect(output).toContain('retrieval_status: timeout');
expect(output).toContain('status: running');
expect(output).toContain('next_step:');
expect(output).toContain('Do not block on it again');
expect(tasks.waitCalls).toEqual([{ taskId, timeoutMs: 1_000 }]);
});
it('cancels a blocking read when the tool execution signal aborts', async () => {
const tasks = new FakeTaskService();
let markWaitStarted: () => void = () => {};
const waitStarted = new Promise<void>((resolve) => {
markWaitStarted = resolve;
});
const taskId = tasks.add(
processTask({ taskId: 'bash-cancel01' }),
outputSnapshot(),
(_timeoutMs, waitSignal) => {
markWaitStarted();
if (waitSignal === undefined) throw new Error('Missing tool execution signal.');
return abortable(new Promise<void>(() => {}), waitSignal);
},
);
const controller = new AbortController();
const execution = executeTool(
new TaskOutputTool(tasks),
context(
'task_output_cancelled',
{ task_id: taskId, block: true, timeout: 60 },
controller.signal,
),
);
await waitStarted;
const reason = userCancellationReason();
controller.abort(reason);
await expect(execution).rejects.toBe(reason);
expect(validateToolArgs(validator, { task_id: 'bash-1' })).toBeNull();
const stale = validateToolArgs(validator, { task_id: 'bash-1', block: true, timeout: 1 });
expect(stale).toContain("must NOT have additional property 'block'");
expect(stale).toContain("must NOT have additional property 'timeout'");
});
it('surfaces timeout terminal metadata', async () => {
@ -573,7 +516,7 @@ describe('TaskOutputTool', () => {
const result = await executeTool(
new TaskOutputTool(tasks),
context('task_output_timed_out', { task_id: taskId, block: true }),
context('task_output_timed_out', { task_id: taskId }),
);
const output = outputString(result);
@ -791,11 +734,12 @@ describe('task tool descriptions', () => {
expectModelFacingParity(new TaskStopTool(tasks), new V1TaskStopTool({} as never));
});
it('TaskOutput description mentions background tasks, block, output_path, and Read', () => {
it('TaskOutput description documents non-blocking snapshots, output_path, and Read', () => {
const description = new TaskOutputTool(tasks).description;
expect(description).toMatch(/background/i);
expect(description).toMatch(/block/);
expect(description).toMatch(/non-blocking/);
expect(description).not.toContain('block=');
expect(description).toMatch(/output_path/);
expect(description).toMatch(/Read/);
expect(description).toContain('run that task in the foreground instead');

View file

@ -79,6 +79,7 @@ interface FakeToolkit {
readonly getCachedAccessToken: ReturnType<typeof vi.fn>;
readonly tokenProvider: ReturnType<typeof vi.fn>;
readonly getManagedUsage: ReturnType<typeof vi.fn>;
readonly getManagedUserInfo: ReturnType<typeof vi.fn>;
}
describe('OAuthService', () => {
@ -155,6 +156,7 @@ describe('OAuthService', () => {
getCachedAccessToken: vi.fn().mockResolvedValue(undefined),
tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }),
getManagedUsage: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }),
getManagedUserInfo: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }),
};
ix = createServices(disposables, {
base: [registerBootstrapServices, registerTelemetryServices],
@ -693,6 +695,30 @@ describe('OAuthService', () => {
});
});
it('getManagedUserInfo resolves the managed runtime auth and delegates to the toolkit', async () => {
const userInfo = {
kind: 'ok' as const,
userInfo: {
userId: 'u_1',
nickname: 'moonwalker',
status: 'USER_STATUS_NORMAL',
region: 'REGION_CN',
userLevel: 30,
userLevelName: 'Vivace',
domain: 1,
domainName: 'DOMAIN_EXAMPLE',
},
};
toolkit.getManagedUserInfo.mockResolvedValue(userInfo);
const svc = createService();
await expect(svc.getManagedUserInfo(OAUTH_PROVIDER)).resolves.toBe(userInfo);
expect(toolkit.getManagedUserInfo).toHaveBeenCalledWith(OAUTH_PROVIDER, {
oauthRef: EXAMPLE_COM_SCOPED_REF,
baseUrl: 'https://api.example.com',
});
});
it('refreshOAuthProviderModels returns an empty result when no Kimi Code provider is configured', async () => {
providers = { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' } };
const svc = createService();

View file

@ -12,6 +12,8 @@ import { BootstrapService } from '#/app/bootstrap/bootstrapService';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { stubClientIdentity } from './stubs';
describe('BootstrapService (scoped)', () => {
beforeEach(() => {
// Keep the registry minimal so unrelated OnScopeCreated services do not run.
@ -26,7 +28,9 @@ describe('BootstrapService (scoped)', () => {
});
it('resolves homeDir/configPath from the seeded context token', () => {
const host = createScopedTestHost(bootstrapSeed({ homeDir: '/tmp/kimi-home' }));
const host = createScopedTestHost(
bootstrapSeed({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity }),
);
const svc = host.app.accessor.get(IBootstrapService);
expect(svc.homeDir).toBe('/tmp/kimi-home');
expect(svc.configPath).toBe('/tmp/kimi-home/config.toml');
@ -34,8 +38,19 @@ describe('BootstrapService (scoped)', () => {
host.dispose();
});
it('exposes the seeded client identity', () => {
const host = createScopedTestHost(
bootstrapSeed({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity }),
);
const svc = host.app.accessor.get(IBootstrapService);
expect(svc.clientIdentity).toEqual(stubClientIdentity);
host.dispose();
});
it('getEnv reads from the seeded env bag', () => {
const host = createScopedTestHost(bootstrapSeed({ env: { FOO: 'bar' } }));
const host = createScopedTestHost(
bootstrapSeed({ env: { FOO: 'bar' }, clientIdentity: stubClientIdentity }),
);
const svc = host.app.accessor.get(IBootstrapService);
expect(svc.getEnv('FOO')).toBe('bar');
expect(svc.getEnv('MISSING')).toBeUndefined();
@ -45,15 +60,32 @@ describe('BootstrapService (scoped)', () => {
describe('resolveBootstrapOptions', () => {
it('prefers explicit homeDir over KIMI_CODE_HOME over osHomeDir', () => {
expect(resolveBootstrapOptions({ homeDir: '/a', osHomeDir: '/b', env: {} }).homeDir).toBe('/a');
expect(resolveBootstrapOptions({ osHomeDir: '/b', env: { KIMI_CODE_HOME: '/c' } }).homeDir).toBe('/c');
expect(resolveBootstrapOptions({ osHomeDir: '/b', env: {} }).homeDir).toBe('/b/.kimi-code');
expect(
resolveBootstrapOptions({ homeDir: '/a', osHomeDir: '/b', env: {}, clientIdentity: stubClientIdentity })
.homeDir,
).toBe('/a');
expect(
resolveBootstrapOptions({
osHomeDir: '/b',
env: { KIMI_CODE_HOME: '/c' },
clientIdentity: stubClientIdentity,
}).homeDir,
).toBe('/c');
expect(
resolveBootstrapOptions({ osHomeDir: '/b', env: {}, clientIdentity: stubClientIdentity }).homeDir,
).toBe('/b/.kimi-code');
});
it('passes through an explicit clientIdentity', () => {
expect(
resolveBootstrapOptions({ env: {}, clientIdentity: stubClientIdentity }).clientIdentity,
).toEqual(stubClientIdentity);
});
});
describe('bootstrap() storage seeding', () => {
it('seeds IFileSystemStorageService as a FileStorageService instance', () => {
const { app } = bootstrap({ homeDir: '/tmp/kimi-home' });
const { app } = bootstrap({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity });
try {
const storage = app.accessor.get(IFileSystemStorageService);
expect(storage).toBeInstanceOf(FileStorageService);

View file

@ -12,6 +12,12 @@ import {
type PersistenceScopeName,
} from '#/app/bootstrap/bootstrap';
export const stubClientIdentity = {
productName: 'test-product',
version: '0.0.0-test',
platform: 'test_platform',
} as const;
export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService {
const sessionsScope = 'sessions';
const scopes: Record<PersistenceScopeName, string> = {
@ -36,7 +42,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv
homeDir,
configPath: `${homeDir}/config.toml`,
configKey: 'config.toml',
clientVersion: '0.0.0-test',
clientIdentity: stubClientIdentity,
sessionsDir: `${homeDir}/sessions`,
blobsDir: `${homeDir}/blobs`,
storeDir: `${homeDir}/store`,

View file

@ -44,6 +44,7 @@ async function makePlugin(
options: {
skills?: boolean;
skillNames?: readonly string[];
agents?: boolean;
version?: string;
sessionStartSkill?: string;
systemPrompt?: string;
@ -70,6 +71,15 @@ async function makePlugin(
);
}
}
if (options.agents === true) {
manifest['agents'] = './agents/';
await mkdir(path.join(root, 'agents'), { recursive: true });
await writeFile(
path.join(root, 'agents', 'demo-agent.md'),
'---\nname: demo-agent\ndescription: A demo agent\n---\nbody',
'utf8',
);
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
@ -179,6 +189,27 @@ describe('PluginManager consumption plane', () => {
});
});
it('pluginAgentRoots() returns only enabled plugins agents paths', async () => {
const home = await makeKimiHome();
const a = await makePlugin('a', { agents: true });
const b = await makePlugin('b', { agents: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(a);
await manager.install(b);
await manager.setEnabled('b', false);
const managedA = await managedPluginRoot(manager, 'a');
const managedB = await managedPluginRoot(manager, 'b');
expect(manager.pluginAgentRoots()).toContainEqual({
path: path.join(managedA, 'agents'),
source: 'plugin',
});
expect(manager.pluginAgentRoots()).not.toContainEqual({
path: path.join(managedB, 'agents'),
source: 'plugin',
});
});
it('pluginSkillRoots() excludes plugins in error state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');

View file

@ -63,6 +63,55 @@ describe('plugin manifest parser', () => {
]);
});
it('resolves explicit agents directories', async () => {
await mkdir(join(dir, 'agents'), { recursive: true });
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['./agents'] }),
'utf8',
);
const result = await parseManifest(dir);
const root = await realpath(dir);
expect(result.manifest?.agents).toEqual([join(root, 'agents')]);
expect(result.diagnostics).toEqual([]);
});
it('defaults agents to the ./agents directory when the field is absent', async () => {
await mkdir(join(dir, 'agents'), { recursive: true });
await writeFile(join(dir, 'kimi.plugin.json'), JSON.stringify({ name: 'demo' }), 'utf8');
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([join(dir, 'agents')]);
expect(result.diagnostics).toEqual([]);
});
it('keeps agents empty when the field is absent and no ./agents directory exists', async () => {
await writeFile(join(dir, 'kimi.plugin.json'), JSON.stringify({ name: 'demo' }), 'utf8');
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([]);
expect(result.diagnostics).toEqual([]);
});
it('warns on invalid agents paths', async () => {
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', agents: ['../outside'] }),
'utf8',
);
const result = await parseManifest(dir);
expect(result.manifest?.agents).toEqual([]);
expect(result.diagnostics.map((d) => d.message)).toEqual([
'"agents" path must start with "./" (got "../outside")',
]);
});
it('reads the systemPrompt field, trimming surrounding whitespace', async () => {
await writeFile(
join(dir, 'kimi.plugin.json'),

View file

@ -331,6 +331,39 @@ describe('FileSessionIndex (read model)', () => {
expect(got?.title).toBe('cached');
});
it('list treats a cache entry missing required fields as a cold miss', async () => {
await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 });
const store = build();
// Mirrors a poisoned entry written before `archived` was normalized to a
// boolean (JSON dropped the undefined field entirely).
await queryStore.put(SESSION_COLLECTION, 's1', {
id: 's1',
workspaceId,
title: 'stale',
createdAt: 1,
updatedAt: 2,
});
const page = await store.list({ workspaceIds: [workspaceId] });
expect(page.items).toHaveLength(1);
expect(page.items[0]?.title).toBe('on-disk');
expect(page.items[0]?.archived).toBe(false);
// The bad entry is overwritten by the disk backfill.
const cached = await queryStore.get<SessionSummary>(SESSION_COLLECTION, 's1');
expect(cached?.archived).toBe(false);
});
it('get falls back to disk when the cached entry fails the shape check', async () => {
await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 });
const store = build();
await queryStore.put(SESSION_COLLECTION, 's1', { id: 's1' });
const got = await store.get('s1');
expect(got?.title).toBe('on-disk');
expect(got?.archived).toBe(false);
});
it('list filters by childOf from the read model', async () => {
await seedSession('child-a', {
createdAt: 2,

View file

@ -11,7 +11,7 @@ import {
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { CloudAppender, type CloudAppenderOptions } from '#/app/telemetry/cloudAppender';
import { stubBootstrap } from '../bootstrap/stubs';
import { stubBootstrap, stubClientIdentity } from '../bootstrap/stubs';
interface CapturedRequest {
readonly url: string;
@ -50,7 +50,7 @@ function baseOptions(
const { homeDir: dir = '', storage, ...rest } = overrides;
return {
storage: storage ?? new FileStorageService(dir),
bootstrap: { ...stubBootstrap(), clientVersion: '1.0.0' },
bootstrap: { ...stubBootstrap(), clientIdentity: { ...stubClientIdentity, version: '1.0.0' } },
deviceId: 'dev',
appName: 'test-app',
sleep: async () => {},

View file

@ -178,6 +178,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog
import { ISessionSwarmService } from '#/session/swarm/sessionSwarm';
import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext';
import { stubClientIdentity } from '../app/bootstrap/stubs';
import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createScriptedGenerate } from './scripted-generate';
@ -537,6 +538,7 @@ export function homeDirServices(homeDir: string | undefined): TestAgentServiceOv
homeDir,
cwd: process.cwd(),
env: process.env,
clientIdentity: stubClientIdentity,
})) {
reg.defineInstance(id, value);
}
@ -1032,6 +1034,7 @@ export class AgentTestContext {
cwd: this.cwd,
osHomeDir: TEST_HOME_DIR,
env: process.env,
clientIdentity: stubClientIdentity,
})) {
reg.defineInstance(id, value);
}

View file

@ -1163,7 +1163,7 @@ describe('BashTool', () => {
expect(persisted.has(taskId!)).toBe(true);
expect(output).toContain(`output_path: /fake/tasks/${taskId}/output.log`);
expect(output).toContain('Use Read with output_path');
expect(output).toContain(`TaskOutput(task_id="${taskId}", block=false)`);
expect(output).toContain(`TaskOutput(task_id="${taskId}")`);
});
it('omits the TaskOutput hint from the saved-output reference when background tools are disabled', async () => {

View file

@ -32,8 +32,11 @@ import {
IUserFileAgentSource,
UserFileAgentSource,
} from '#/app/agentFileCatalog/userFileAgentSource';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IPluginService } from '#/app/plugin/plugin';
import type { ReloadSummary } from '#/app/plugin/types';
import '#/index';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
@ -45,6 +48,10 @@ import {
ExtraFileAgentSource,
IExtraFileAgentSource,
} from '#/session/sessionAgentProfileCatalog/extraFileAgentSource';
import {
IPluginAgentProfileSource,
PluginAgentProfileSource,
} from '#/session/sessionAgentProfileCatalog/pluginAgentProfileSource';
import {
IProjectFileAgentSource,
ProjectFileAgentSource,
@ -113,6 +120,33 @@ function workspaceStub(workDir: string): ISessionWorkspaceContext {
};
}
function pluginStub(
agentRoots: readonly AgentFileRoot[] = [],
reloadEmitter?: Emitter<ReloadSummary>,
): IPluginService {
return {
_serviceBrand: undefined,
onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }),
listPlugins: async () => [],
installPlugin: async () => ({ id: '' }) as never,
setPluginEnabled: async () => {},
setPluginMcpServerEnabled: async () => {},
removePlugin: async () => {},
reloadPlugins: async () => ({ added: [], removed: [], errors: [] }),
getPluginInfo: async () => {
throw new Error('getPluginInfo is not used by these tests');
},
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => [],
pluginAgentRoots: async () => agentRoots,
enabledSessionStarts: async () => [],
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),
enabledHooks: async () => [],
};
}
function agentMd(name: string, description: string, override = false): string {
const overrideLine = override ? 'override: true\n' : '';
return `---\nname: ${name}\ndescription: ${description}\n${overrideLine}---\n\nYou are ${name}.\n`;
@ -174,6 +208,8 @@ function makeSession(
readonly logWarnings?: string[];
readonly userSource?: IUserFileAgentSource;
readonly explicitSource?: IExplicitFileAgentSource;
readonly pluginAgentRoots?: readonly AgentFileRoot[];
readonly pluginReloadEmitter?: Emitter<ReloadSummary>;
},
) {
const config = configStub();
@ -190,6 +226,10 @@ function makeSession(
stubPair(IConfigService, config),
stubPair(IAgentCatalogRuntimeOptions, runtimeOptions),
stubPair(ILogService, logStub()),
stubPair(
IPluginService,
pluginStub(opts?.pluginAgentRoots ?? [], opts?.pluginReloadEmitter),
),
...(opts?.userSource ? [stubPair(IUserFileAgentSource, opts.userSource)] : []),
]);
const session = host.child(LifecycleScope.Session, 's1', [
@ -248,6 +288,11 @@ describe('SessionAgentProfileCatalogService', () => {
IProjectFileAgentSource,
ProjectFileAgentSource,
);
registerScopedService(
LifecycleScope.Session,
IPluginAgentProfileSource,
PluginAgentProfileSource,
);
});
it('lists builtin profiles when no agent directories exist', async () => {
@ -311,6 +356,47 @@ describe('SessionAgentProfileCatalogService', () => {
});
});
it('merges plugin agents below user; user wins on name collision', async () => {
await withFixture(async (fixture) => {
const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents');
await writeAgent(pluginAgentsDir, 'shared.md', agentMd('shared', 'from plugin'));
await writeAgent(pluginAgentsDir, 'plugin-only.md', agentMd('plugin-only', 'from plugin'));
await writeAgent(join(fixture.homeDir, 'agents'), 'shared.md', agentMd('shared', 'from user'));
const { host, session } = makeSession(fixture, {
pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }],
});
const catalog = session.accessor.get(ISessionAgentProfileCatalog);
await catalog.load();
expect(catalog.get('shared')?.description).toBe('from user');
expect(catalog.get('plugin-only')?.description).toBe('from plugin');
host.dispose();
});
});
it('reloads the plugin source when plugins reload', async () => {
await withFixture(async (fixture) => {
const pluginAgentsDir = join(fixture.extraDir, 'plugin-agents');
await mkdir(pluginAgentsDir, { recursive: true });
const reloadEmitter = new Emitter<ReloadSummary>();
const { host, session } = makeSession(fixture, {
pluginAgentRoots: [{ path: pluginAgentsDir, source: 'plugin' }],
pluginReloadEmitter: reloadEmitter,
});
const catalog = session.accessor.get(ISessionAgentProfileCatalog);
await catalog.load();
expect(catalog.get('late')).toBeUndefined();
await writeAgent(pluginAgentsDir, 'late.md', agentMd('late', 'late plugin agent'));
const changed = waitForEvent(catalog.onDidChange);
reloadEmitter.fire({ added: [], removed: [], errors: [] });
await changed;
expect(catalog.get('late')?.description).toBe('late plugin agent');
host.dispose();
});
});
it('fails ready when an explicit agent file is invalid', async () => {
await withFixture(async (fixture) => {
const bad = await writeAgent(

View file

@ -93,6 +93,36 @@ describe('SessionMetadata', () => {
expect(await meta.read()).toMatchObject({ title: 't', archived: true });
});
it('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => {
// A state.json written before `archived` existed: normalizeSessionMeta
// keeps the field undefined, and a naive mirror would drop the key from
// the cached JSON entirely (failing the read-model contract on reads).
const store = ix.get(IAtomicDocumentStore);
await store.set(META_SCOPE, 'state.json', {
id: 's1',
version: 2,
createdAt: 1700000000000,
updatedAt: 1700000000000,
agents: {},
custom: {},
});
const writes: unknown[] = [];
ix.stub(IQueryStore, {
...stubQueryStore(),
put: async (_c: string, _k: string, value: unknown) => {
writes.push(value);
},
});
ix.stub(IFlagService, stubFlag(true));
const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'x' });
expect(writes).toHaveLength(1);
expect(writes[0]).toMatchObject({ id: 's1', archived: false });
});
it('persists across instances', async () => {
const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'persisted' });

View file

@ -118,6 +118,7 @@ function pluginStub(
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => skillRoots,
pluginAgentRoots: async () => [],
enabledSessionStarts: async () => [],
enabledSystemPrompts: async () => [],
enabledMcpServers: async () => ({}),

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,14 @@
# @moonshot-ai/agent-core
## 0.15.7
### Patch Changes
- [#2382](https://github.com/MoonshotAI/kimi-code/pull/2382) [`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa) Thanks [@liruifengv](https://github.com/liruifengv)! - Thread the host identity through the managed auth facades so OAuth token refreshes from inside the core carry the `X-Msh-*` device headers.
- Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
- @moonshot-ai/kimi-code-oauth@0.3.0
## 0.15.6
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/agent-core",
"version": "0.15.6",
"version": "0.15.7",
"private": true,
"description": "The unified agent engine for Kimi",
"license": "MIT",

View file

@ -10,7 +10,7 @@ import { TodoListReminderInjector } from './todo-list';
import { ToolsDiffInjector } from './tools-diff';
const ACTIVE_BACKGROUND_TASK_GUIDANCE =
'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before. Do not start duplicates. Use TaskOutput to fetch a tasks result, TaskList to list them, and TaskStop to cancel one.';
'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before. Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot, and TaskStop to cancel one — completion arrives via automatic notification.';
export class InjectionManager {
private readonly injectors: DynamicInjector[];

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import type { McpServerConfig } from '../config/schema';
import type { AgentFileRoot } from '../profile/agentfile/types';
import { discoverSkills, type SkillRoot } from '../skill';
import type { HookDef } from '../session/hooks';
import { loadPluginCommand } from './commands';
@ -216,6 +217,17 @@ export class PluginManager {
return roots;
}
pluginAgentRoots(): readonly AgentFileRoot[] {
const roots: AgentFileRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.agents ?? []) {
roots.push({ path: dir, source: 'plugin' });
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {

View file

@ -103,7 +103,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
let skills = await resolveDirListField(pluginRoot, 'skills', raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
@ -111,6 +111,14 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
}
}
let agents = await resolveDirListField(pluginRoot, 'agents', raw['agents'], diagnostics);
if (raw['agents'] === undefined) {
const agentsDir = path.join(pluginRoot, 'agents');
if (await isDir(agentsDir)) {
agents = [agentsDir];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
@ -127,6 +135,7 @@ export async function parseManifest(pluginRoot: string): Promise<ParsedManifestR
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
agents,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
@ -152,8 +161,9 @@ function recordUnsupportedRuntimeFields(
}
}
async function resolveSkillsField(
async function resolveDirListField(
pluginRoot: string,
field: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
@ -164,7 +174,7 @@ async function resolveSkillsField(
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
diagnostics.push({ severity: 'error', message: `"${field}" must be a string or string[]` });
return [];
}
@ -173,7 +183,7 @@ async function resolveSkillsField(
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
message: `"skills" path must start with "./" (got "${entry}")`,
message: `"${field}" path must start with "./" (got "${entry}")`,
});
continue;
}
@ -188,14 +198,14 @@ async function resolveSkillsField(
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
message: `"${field}" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
message: `"skills" path is not a directory (${entry})`,
message: `"${field}" path is not a directory (${entry})`,
});
continue;
}

View file

@ -33,6 +33,7 @@ export interface PluginManifest {
readonly homepage?: string;
readonly license?: string;
readonly skills?: readonly string[]; // resolved absolute paths
readonly agents?: readonly string[]; // resolved absolute paths
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly hooks?: readonly HookDefConfig[];

View file

@ -2,7 +2,8 @@
* Session-level agent profile catalog.
*
* Merges the builtin (code-embedded) profiles with the file-backed sources
* (user / extra / project / explicit) by priority, requiring an explicit
* (plugin / user / extra / project / explicit) by priority, requiring an
* explicit
* opt-in (`override: true`) before a file replaces a same-name builtin. The
* merged view always contains the builtin profiles (seeded at construction);
* file profiles appear once `ready` resolves. A failing `explicit` source (an
@ -34,6 +35,7 @@ import { describeInactiveToolPattern, findInactiveToolPatterns } from './validat
import {
AgentProfileCatalogSnapshotSchema,
type AgentFileDefinition,
type AgentFileRoot,
type AgentFileSource,
type AgentProfileCatalogSnapshot,
} from './types';
@ -48,10 +50,13 @@ export interface SessionAgentCatalogOptions {
readonly osHomeDir: string;
readonly extraDirs?: readonly string[];
readonly explicitFiles?: readonly string[];
/** Agent directories contributed by enabled plugins (lowest file priority). */
readonly pluginRoots?: readonly AgentFileRoot[];
readonly warn?: (message: string, error?: unknown) => void;
}
const SOURCE_PRIORITY: Readonly<Record<AgentFileSource, number>> = {
plugin: 5,
user: 10,
extra: 20,
project: 30,
@ -123,6 +128,44 @@ export class SessionAgentProfileCatalog {
/** Replace live discovery with the file-backed catalog bound at creation. */
restoreSnapshot(snapshot: AgentProfileCatalogSnapshot): void {
const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot);
const { entries } = this.entriesFromSnapshot(restored);
this.applyFileEntries(entries);
this.snapshotValue = restored;
}
/** Replace only the persisted plugin layer while keeping the session-bound local profiles. */
async restoreSnapshotRefreshingPlugins(
snapshot: AgentProfileCatalogSnapshot,
pluginRoots: readonly AgentFileRoot[],
): Promise<void> {
const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot);
const { effectiveDefault, entries, systemMd } = this.entriesFromSnapshot(
restored,
(profile) => profile.source !== 'plugin',
);
if (pluginRoots.length > 0) {
const discovered = await discoverAgentFiles(pluginRoots, this.warn);
for (const definition of discovered.agents) {
this.warnInactivePatterns(definition);
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
}
const winners = this.applyFileEntries(entries);
this.snapshotValue = this.snapshotFromEntries(winners, systemMd);
}
private entriesFromSnapshot(
restored: AgentProfileCatalogSnapshot,
includeProfile: (
profile: AgentProfileCatalogSnapshot['profiles'][number],
) => boolean = () => true,
): {
readonly effectiveDefault: ResolvedAgentProfile;
readonly entries: FileProfileEntry[];
readonly systemMd: AgentFileDefinition | undefined;
} {
this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES));
const builtinDefault = this.getDefault();
@ -137,6 +180,7 @@ export class SessionAgentProfileCatalog {
entries.push(this.systemMdEntry(systemMd, effectiveDefault));
}
for (const profile of restored.profiles) {
if (!includeProfile(profile)) continue;
const definition: AgentFileDefinition = {
name: profile.name,
description: profile.description,
@ -148,13 +192,12 @@ export class SessionAgentProfileCatalog {
modelPreference: profile.modelPreference,
prompt: profile.prompt,
path: `<session-agent-profile:${profile.name}>`,
source: 'explicit',
source: profile.source ?? 'explicit',
};
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
this.applyFileEntries(entries);
this.snapshotValue = restored;
return { effectiveDefault, entries, systemMd };
}
/**
@ -217,6 +260,15 @@ export class SessionAgentProfileCatalog {
}
}
const pluginRoots = this.options.pluginRoots ?? [];
if (pluginRoots.length > 0) {
const discovered = await discoverAgentFiles(pluginRoots, warn);
for (const definition of discovered.agents) {
this.warnInactivePatterns(definition);
entries.push(this.entryFromDefinition(definition, effectiveDefault));
}
}
// ── Explicit source (fatal) ──────────────────────────────────────
// Match v2's per-source merge semantics: when several explicit files
// declare the same profile name, the last file replaces the earlier one.
@ -355,6 +407,7 @@ export class SessionAgentProfileCatalog {
subagents: Object.keys(profile.subagents ?? {}),
modelPreference: profile.modelPreference,
prompt: definition.prompt,
source: definition.source,
}));
if (systemMd === undefined && profiles.length === 0) return undefined;
return AgentProfileCatalogSnapshotSchema.parse({

Some files were not shown because too many files have changed in this diff Show more