Merge branch 'main' into auto-title

Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
7Sageer 2026-07-31 17:01:57 +08:00 committed by GitHub
commit 64099bd801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 766 additions and 413 deletions

View file

@ -67,4 +67,4 @@ Invariants that hold across every stage. Each is expanded in the stage file note
9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md)
10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md)
11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md)
12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md)
12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md)

View file

@ -42,9 +42,16 @@ If it fails any rule, it is not Config:
**`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by
all domains — the env bag, resolved paths, and host facts (`platform`, `arch`,
`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific
upper domain (no `cron`, no `flags`, no feature-specific fields): that couples
the foundational layer to an upstream one.
`cwd`, `osHomeDir`, `isCI`, …) — plus the host's process-level invocation
arguments in `args` (explicit `agentFiles` / `skillDirs`, `requestHeaders`,
prompt identity). `args` mirrors VS Code's `NativeParsedArgs` on the
environment service: the host states them once via `BootstrapInput.args` at
the composition root, and downstream services read them from
`IBootstrapService.args` instead of through per-domain runtime-options
services (do not add new `IXxxRuntimeOptions` services or seed functions for
host parameters). What must **never** land on `IBootstrapService` is state
tied to a specific upper domain (no `cron`, no `flags`, no feature-specific
fields): that couples the foundational layer to an upstream one.
Any value that belongs to a specific domain — including env-only operational
toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature
@ -264,7 +271,7 @@ The authoritative, always-current list of registered sections — rendered in th
- `config` never imports the domains that consume it — keep section schemas in the owning domain.
- Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code.
- Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays.
- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`.
- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`.
- Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections.
- `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`.
- Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file.

View file

@ -37,13 +37,11 @@ import {
ITelemetryService,
PRINT_MAX_TURNS_DEFAULT,
PRINT_WAIT_CEILING_S_DEFAULT,
agentCatalogRuntimeOptionsSeed,
applyPrintModeConfigDefaults,
bootstrap,
createCloudAppender,
ensureMainAgent,
resumeSessionById,
hostRequestHeadersSeed,
logSeed,
parseAgentFileText,
resolveAgentPath,
@ -51,7 +49,6 @@ import {
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
skillCatalogRuntimeOptionsSeed,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
@ -130,18 +127,24 @@ export async function runV2Print(
const identity = createKimiCodeHostIdentity(version);
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
const { app } = bootstrap({ homeDir, clientIdentity: identity }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
// `--agent-file`: explicit agent definition files, registered with the
// highest-precedence source for this process. Passed through unresolved —
// the engine expands `~` and resolves relative paths against the session
// workDir (mirroring `--skills-dir`).
...agentCatalogRuntimeOptionsSeed(opts.agentFiles),
]);
const { app } = bootstrap(
{
homeDir,
clientIdentity: identity,
args: {
requestHeaders: hostHeaders,
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
skillDirs: opts.skillsDirs,
// `--agent-file`: explicit agent definition files, registered with the
// highest-precedence source for this process. Passed through unresolved —
// the engine expands `~` and resolves relative paths against the session
// workDir (mirroring `--skills-dir`).
agentFiles: opts.agentFiles,
},
},
[...logSeed(logging)],
);
const auth = app.accessor.get(IOAuthToolkit);
const configService = app.accessor.get(IConfigService);

View file

@ -1,4 +1,11 @@
import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import {
removeProviderFromConfig,
type CreateSessionOptions,
type KimiConfig,
type KimiHarness,
type OAuthRef,
type Session,
} from '@moonshot-ai/kimi-code-sdk';
import { createKimiCodeUserAgent } from '#/cli/version';
@ -7,6 +14,7 @@ import type { SkillListSession } from '../commands';
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
import {
refreshAllProviderModels,
type RefreshProviderHost,
type RefreshProviderScope,
type RefreshResult,
} from '../utils/refresh-providers';
@ -172,23 +180,68 @@ export class AuthFlowController {
}
private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> {
const { host } = this;
const result = await refreshAllProviderModels(
{
getConfig: () => host.harness.getConfig({ reload: true }),
removeProvider: (id) => host.harness.removeProvider(id),
setConfig: (patch) => host.harness.setConfig(patch),
resolveOAuthToken: async (providerName, oauthRef) => {
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
return tokenProvider.getAccessToken();
},
userAgent: createKimiCodeUserAgent(),
},
{ scope },
);
const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope });
if (result.changed.length > 0) {
await this.refreshAvailableModels();
}
return result;
}
/**
* Build the refresh orchestrator's persistence host. When the harness can
* persist several config sections as ONE atomic write (the v2 engine's
* `replaceSections`), the orchestrator's two-phase contract (removeProvider
* then setConfig) is absorbed the same way the v2 engine's own refresh path
* does it: the removal is staged in memory only, and the following
* setConfig persists the complete records in a single write so a process
* exit mid-refresh can never leave config.toml in a "provider removed, not
* yet restored" state. The v1 harness keeps the legacy host (two
* whole-document writes, each atomic on its own).
*/
private buildRefreshHost(): RefreshProviderHost {
const { host } = this;
const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise<string> => {
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
return tokenProvider.getAccessToken();
};
const userAgent = createKimiCodeUserAgent();
if (!host.harness.supportsAtomicSectionReplace()) {
return {
getConfig: () => host.harness.getConfig({ reload: true }),
removeProvider: (id) => host.harness.removeProvider(id),
setConfig: (patch) => host.harness.setConfig(patch),
resolveOAuthToken,
userAgent,
};
}
let staged: KimiConfig | undefined;
const requireStaged = (): KimiConfig => {
if (staged === undefined) {
throw new Error('refresh host: getConfig must be called before writes');
}
return staged;
};
return {
getConfig: async () => {
staged = await host.harness.getConfig({ reload: true });
return staged;
},
removeProvider: (id) => {
staged = removeProviderFromConfig(requireStaged(), id);
return Promise.resolve(staged);
},
setConfig: async (patch) => {
// The orchestrator always passes complete records (built from a full
// clone), so the Partial-shaped patch is a full KimiConfig overlay.
staged = { ...requireStaged(), ...patch } as KimiConfig;
// Object.entries keeps keys whose value is `undefined`, so a cleared
// section (e.g. a dangling defaultModel) is expressed as a removal in
// the atomic write; sections absent from the patch stay untouched.
await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch)));
return staged;
},
resolveOAuthToken,
userAgent,
};
}
}

View file

@ -325,6 +325,7 @@ export class KimiTUI {
private uninstallRainbowDance: () => void;
private signalCleanupHandlers: Array<() => void> = [];
private isShuttingDown = false;
private backgroundRefreshPromise: Promise<void> | undefined;
private readonly migrationPlan: MigrationPlan | null;
private readonly migrateOnly: boolean;
private readonly engineV2: boolean;
@ -748,7 +749,7 @@ export class KimiTUI {
private async init(): Promise<boolean> {
setExperimentalFeatures(await this.harness.getExperimentalFeatures());
await this.authFlow.refreshAvailableModels();
void this.refreshProviderModelsInBackground();
this.backgroundRefreshPromise = this.refreshProviderModelsInBackground();
const { startup } = this.options;
const { workDir } = this.state.appState;
@ -852,6 +853,16 @@ export class KimiTUI {
this.isShuttingDown = true;
this.unregisterSignalHandlers();
this.aborted = true;
// Give the startup provider-model refresh a brief chance to finish before
// the harness closes (and the process exits): its config writes are each
// atomic, so draining can only ever leave a complete file behind. Bounded
// so a slow network never delays the exit.
if (this.backgroundRefreshPromise !== undefined) {
await Promise.race([
this.backgroundRefreshPromise,
new Promise((resolve) => setTimeout(resolve, 1500)),
]);
}
this.streamingUI.discardPending();
// Stop background polling, streaming intervals, and per-component timers
// before tearing the UI down, so they can't keep firing requestRender after

View file

@ -5,7 +5,6 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
IAgentCatalogRuntimeOptions,
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
@ -22,10 +21,9 @@ import {
ISessionIndex,
ISessionLifecycleService,
IWorkspaceLifecycleService,
ISkillCatalogRuntimeOptions,
ITelemetryService,
type BootstrapInput,
type DomainEvent,
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';
import { runV2Print } from '../../src/cli/v2/run-v2-print';
@ -295,7 +293,7 @@ describe('runV2Print', () => {
expect(app.dispose).toHaveBeenCalled();
});
it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => {
it('passes explicit skill dirs from --skillsDir into bootstrap args', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();
@ -308,12 +306,11 @@ describe('runV2Print', () => {
stderr,
});
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] });
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.skillDirs).toEqual(['/skills']);
});
it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => {
it('leaves the skill dirs arg unset when --skillsDir is empty', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();
@ -323,8 +320,8 @@ describe('runV2Print', () => {
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false);
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.skillDirs ?? []).toEqual([]);
});
it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => {
@ -341,9 +338,8 @@ describe('runV2Print', () => {
{ stdout, stderr },
);
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] });
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.agentFiles).toEqual(['/agents/reviewer.md']);
const lifecycle = handlerServices.get(ISessionLifecycleService) as {
create: ReturnType<typeof vi.fn>;
@ -376,9 +372,8 @@ describe('runV2Print', () => {
stderr,
});
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] });
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.agentFiles).toEqual([agentFile]);
const lifecycle = handlerServices.get(ISessionLifecycleService) as {
create: ReturnType<typeof vi.fn>;
@ -430,7 +425,7 @@ describe('runV2Print', () => {
expect(profile.bind).not.toHaveBeenCalled();
});
it('leaves the agent runtime options unseeded when --agentFile is empty', async () => {
it('leaves the agent files arg unset when --agentFile is empty', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent } = makeFakeHarness();
@ -440,8 +435,8 @@ describe('runV2Print', () => {
await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
expect(seeds.some(([id]) => id === IAgentCatalogRuntimeOptions)).toBe(false);
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.agentFiles ?? []).toEqual([]);
});
it('passes --agent-file paths through unresolved so the engine can expand ~', async () => {
@ -458,9 +453,8 @@ describe('runV2Print', () => {
{ stdout, stderr },
);
const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed;
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitFiles: ['~/agents/reviewer.md'] });
const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput;
expect(input.args?.agentFiles).toEqual(['~/agents/reviewer.md']);
});
it('treats re-selecting the already-bound profile on resume as a no-op', async () => {

View file

@ -206,6 +206,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown>
track: vi.fn(),
setTelemetryContext: vi.fn(),
getExperimentalFeatures: vi.fn(async () => []),
supportsAtomicSectionReplace: vi.fn(() => false),
auth: {
status: vi.fn(async () => ({ providers: [] })),
login: vi.fn(async () => {}),
@ -1140,6 +1141,121 @@ describe('KimiTUI startup', () => {
expect(showStatus).toHaveBeenCalledWith("New Models · +2 models.");
});
it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => {
const registryUrl = "https://registry.example.test/v1/models/api.json";
const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" };
const replaceConfigSections = vi.fn(async (_sections: Record<string, unknown>) => {});
const removeProvider = vi.fn(async () => ({}));
const setConfig = vi.fn(async () => ({}));
const harness = makeHarness(makeSession(), {
supportsAtomicSectionReplace: vi.fn(() => true),
replaceConfigSections,
removeProvider,
setConfig,
getConfig: vi.fn(async () => ({
providers: {
a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source },
b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source },
},
models: {
"a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
"b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
},
defaultModel: "b/m1",
thinking: { enabled: true },
})),
});
const driver = makeDriver(harness, makeStartupInput());
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
a: {
id: "a",
name: "Provider A",
api: "https://a.example.test/v1",
type: "openai",
models: { m1: { id: "m1" } },
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
try {
const result = await (driver as any).authFlow.refreshProviderModels();
expect(result.failed).toEqual([]);
expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 });
// The removal was staged in memory: no destructive pre-write, exactly
// one atomic section replace carrying the complete records — with the
// dangling default model / thinking expressed as cleared sections.
expect(removeProvider).not.toHaveBeenCalled();
expect(setConfig).not.toHaveBeenCalled();
expect(replaceConfigSections).toHaveBeenCalledTimes(1);
const sections = replaceConfigSections.mock.calls[0]?.[0] as Record<string, unknown>;
expect(Object.keys(sections["providers"] as object)).toEqual(["a"]);
expect(sections["models"]).not.toHaveProperty("b/m1");
expect(sections["defaultModel"]).toBeUndefined();
expect(sections["thinking"]).toBeUndefined();
} finally {
vi.unstubAllGlobals();
}
});
it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => {
const registryUrl = "https://registry.example.test/v1/models/api.json";
const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" };
const replaceConfigSections = vi.fn(async () => {});
const removeProvider = vi.fn(async () => ({}));
const setConfig = vi.fn(async (patch: Record<string, unknown>) => patch);
const harness = makeHarness(makeSession(), {
replaceConfigSections,
removeProvider,
setConfig,
getConfig: vi.fn(async () => ({
providers: {
a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source },
b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source },
},
models: {
"a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
"b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] },
},
defaultModel: "b/m1",
})),
});
const driver = makeDriver(harness, makeStartupInput());
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
a: {
id: "a",
name: "Provider A",
api: "https://a.example.test/v1",
type: "openai",
models: { m1: { id: "m1" } },
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
try {
const result = await (driver as any).authFlow.refreshProviderModels();
expect(result.failed).toEqual([]);
expect(removeProvider).toHaveBeenCalledWith("b");
expect(setConfig).toHaveBeenCalledTimes(1);
expect(replaceConfigSections).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("starts TUI without a session when fresh startup needs OAuth login", async () => {
const harness = makeHarness(makeSession(), {
createSession: vi.fn(async () => {

View file

@ -25,9 +25,11 @@
* Kosong directories that do not exist yet are skipped silently (later
* refactor phases add them).
*
* Intra-package relative imports and `#/`-alias imports are resolved against
* `src/`. Sibling packages (`@moonshot-ai/*` other than v1) and third-party
* imports are out of scope (except for the kosong purity bans above).
* Intra-package relative imports, `#/`-alias imports, and the package's
* self-reference (`@moonshot-ai/agent-core-v2/<path>` `src/<path>`) are
* resolved against `src/`. Sibling packages (`@moonshot-ai/*` other than v1)
* and third-party imports are out of scope (except for the kosong purity
* bans above).
*
* Run: `node scripts/check-import-boundaries.mjs`. Exits non-zero on violation.
*/
@ -42,6 +44,7 @@ export const SRC_ROOT = join(PKG_ROOT, 'src');
const TEST_ROOT = join(PKG_ROOT, 'test');
const V1_PACKAGE = '@moonshot-ai/agent-core';
const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/';
/**
* Scope directories introduced by the `src/{scope}/{domain}` layout. A path's
@ -172,6 +175,11 @@ function resolveIntraV2(specifier, fromFile) {
if (specifier.startsWith('#/')) {
return join(SRC_ROOT, specifier.slice(2));
}
// The package's legal self-reference: `@moonshot-ai/agent-core-v2/x` maps
// to `src/x` via the `./*` export.
if (specifier.startsWith(SELF_PACKAGE_PREFIX)) {
return join(SRC_ROOT, specifier.slice(SELF_PACKAGE_PREFIX.length));
}
if (specifier.startsWith('.')) {
return resolve(dirname(fromFile), specifier);
}

View file

@ -107,7 +107,6 @@ import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryCon
import { IWireService } from '#/wire/wire';
import type { PayloadOf } from '#/wire/types';
import { IEventBus } from '#/app/event/eventBus';
import { IHostIdentity } from '#/app/hostIdentity/hostIdentity';
import { prepareSystemPromptContext, type LoadedAgentsMd } from './context';
import type {
ApplyProfileOptions,
@ -220,7 +219,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader,
@IAgentStateService private readonly states: IAgentStateService,
@IHostIdentity private readonly hostIdentity: IHostIdentity,
@IPluginService private readonly plugins: IPluginService,
) {
super();
@ -871,8 +869,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
skills,
pluginSections,
skillActive: this.isToolActiveForProfile(profile, 'Skill'),
productName: this.hostIdentity.productName,
replyStyleGuide: this.hostIdentity.replyStyleGuide,
productName: this.bootstrap.args.displayName,
replyStyleGuide: this.bootstrap.args.replyStyleGuide,
};
}

View file

@ -9,8 +9,9 @@
* state after a successful Kimi login), whose bearer token comes from
* `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from
* the provider's `baseUrl`. The explicit config wins over the managed
* derivation. Both use the host's Kimi identity headers (`IHostRequestHeaders`)
* as default headers. When neither source is configured it yields `undefined`.
* derivation. Both use the host's Kimi identity headers
* (`IBootstrapService.args.requestHeaders`) as default headers. When neither
* source is configured it yields `undefined`.
* Tests and hosts that need a custom backend bind `IWebSearchProviderService`
* directly. Bound at App scope.
*/
@ -22,8 +23,8 @@ import {
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IOAuthService } from '#/app/auth/auth';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IProviderService } from '#/kosong/provider/provider';
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
@ -38,7 +39,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
constructor(
@IProviderService private readonly providers: IProviderService,
@IOAuthService private readonly oauth: IOAuthService,
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IConfigService private readonly config: IConfigService,
) {}
@ -59,7 +60,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
baseUrl: search.baseUrl,
tokenProvider,
apiKey: nonEmptyString(search.apiKey),
defaultHeaders: { ...this.hostHeaders.headers },
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
customHeaders: search.customHeaders,
});
}
@ -80,7 +81,7 @@ export class WebSearchProviderService implements IWebSearchProviderService {
return new MoonshotWebSearchProvider({
baseUrl,
tokenProvider,
defaultHeaders: { ...this.hostHeaders.headers },
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
customHeaders: provider.customHeaders,
});
}

View file

@ -3,8 +3,12 @@
*
* 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`, `clientIdentity`) and the
* app path layout (`homeDir`, `configPath`, ). `resolveBootstrapOptions` is
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`), the
* app path layout (`homeDir`, `configPath`, ), and the host's process-level
* invocation arguments (`args` mirroring VS Code's `NativeParsedArgs`
* carried on the environment service: the host states them once in
* `BootstrapInput`; downstream services read them here instead of through
* per-domain runtime-options services). `resolveBootstrapOptions` is
* the single place that reads `process.env` / `os.homedir()` / invocation
* input to resolve the snapshot; everything downstream reads from
* `IBootstrapService` instead of touching `process` directly. Bound at App
@ -30,6 +34,56 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe
import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
/**
* Host invocation arguments process-level overrides the embedding host
* states once at startup (mirrors VS Code's `NativeParsedArgs` carried on the
* environment service). Resolved from {@link HostArgsInput} and read via
* `IBootstrapService.args`.
*/
export interface HostArgs {
/**
* Explicit agent definition files for this process (the CLI's
* `--agent-file`): loaded as the highest-priority `explicit` agent-profile
* source. Undefined means no explicit files.
*/
readonly agentFiles?: readonly string[];
/**
* Explicit skill directories for this process (v1's SDK `skillDirs`): when
* non-empty, default user / project skill discovery is skipped and these
* directories serve as the user skill source.
*/
readonly skillDirs?: readonly string[];
/**
* Host identity headers applied to outbound provider requests (User-Agent +
* `X-Msh-*`, built by the host through `createKimiDefaultHeaders`).
* Materialized to `{}` when the host passes none.
*/
readonly requestHeaders: Readonly<Record<string, string>>;
/** Fills the `${product_name}` slot in the base system-prompt template. */
readonly displayName?: string;
/** Replaces the `${reply_style_guide}` block in the base system prompt. */
readonly replyStyleGuide?: string;
}
/** {@link HostArgs} as accepted from the host: `requestHeaders` may be omitted. */
export interface HostArgsInput {
readonly agentFiles?: readonly string[];
readonly skillDirs?: readonly string[];
readonly requestHeaders?: Readonly<Record<string, string>>;
readonly displayName?: string;
readonly replyStyleGuide?: string;
}
export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs {
return {
agentFiles: input?.agentFiles,
skillDirs: input?.skillDirs,
requestHeaders: input?.requestHeaders ?? {},
displayName: input?.displayName,
replyStyleGuide: input?.replyStyleGuide,
};
}
export interface IBootstrapOptions {
readonly homeDir: string;
readonly configPath: string;
@ -39,6 +93,7 @@ export interface IBootstrapOptions {
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly clientIdentity: KimiHostIdentity;
readonly args: HostArgs;
}
export const IBootstrapOptions: ServiceIdentifier<IBootstrapOptions> =
@ -64,6 +119,8 @@ export interface IBootstrapService {
readonly homeDir: string;
readonly configPath: string;
readonly clientIdentity: KimiHostIdentity;
/** Host invocation arguments; see {@link HostArgs}. */
readonly args: HostArgs;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
@ -85,7 +142,11 @@ export interface BootstrapInput {
readonly platform?: NodeJS.Platform;
readonly arch?: string;
readonly cwd?: string;
/** Required: every process names its host. There is deliberately no default
a fabricated identity would silently misreport the host upstream. */
readonly clientIdentity: KimiHostIdentity;
/** Host invocation arguments; see {@link HostArgsInput}. */
readonly args?: HostArgsInput;
}
export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions {
@ -102,6 +163,7 @@ export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOption
cwd: input.cwd ?? process.cwd(),
env,
clientIdentity: input.clientIdentity,
args: resolveHostArgs(input.args),
};
}

View file

@ -18,6 +18,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
import {
IBootstrapOptions,
IBootstrapService,
type HostArgs,
type PersistenceScopeName,
} from './bootstrap';
@ -31,6 +32,7 @@ export class BootstrapService implements IBootstrapService {
readonly homeDir: string;
readonly configPath: string;
readonly clientIdentity: KimiHostIdentity;
readonly args: HostArgs;
readonly sessionsDir: string;
readonly blobsDir: string;
readonly storeDir: string;
@ -50,6 +52,7 @@ export class BootstrapService implements IBootstrapService {
this.homeDir = options.homeDir;
this.configPath = options.configPath;
this.clientIdentity = options.clientIdentity;
this.args = options.args;
this.sessionsDir = join(options.homeDir, 'sessions');
this.blobsDir = join(options.homeDir, 'blobs');
this.storeDir = join(options.homeDir, 'store');

View file

@ -202,7 +202,16 @@ export interface IConfigService {
inspect<T = unknown>(domain: string): ConfigInspectValue<T>;
getAll(): ResolvedConfig;
set(domain: string, patch: unknown, target?: ConfigTarget): Promise<void>;
/**
* Replace one domain wholesale; `undefined` (or `null`, the wire encoding
* of clear JSON transports cannot carry `undefined`) removes the domain.
*/
replace(domain: string, value: unknown, target?: ConfigTarget): Promise<void>;
/**
* Replace several domains in ONE atomic write: a domain mapped to
* `undefined` (or `null`, see {@link replace}) is cleared, domains absent
* from `sections` are left untouched.
*/
replaceSections(
sections: Readonly<Record<string, unknown>>,
target?: ConfigTarget,

View file

@ -331,17 +331,20 @@ export class ConfigService extends Disposable implements IConfigService {
target: ConfigTarget = ConfigTarget.User,
): Promise<void> {
await this.ready;
// `null` is the wire encoding of "clear this domain": JSON transports
// (klient memory/ipc, kap-server REST/WS) cannot carry `undefined`.
const effectiveValue = value === null ? undefined : value;
if (target === ConfigTarget.Memory) {
if (value === undefined) {
if (effectiveValue === undefined) {
delete this.memory[domain];
} else {
this.memory[domain] = this.registry.validate(domain, value);
this.memory[domain] = this.registry.validate(domain, effectiveValue);
}
this.commit('set', [domain]);
return;
}
await this.enqueueStateTransition(async () => {
const stripped = this.stripEnv(domain, value);
const stripped = this.stripEnv(domain, effectiveValue);
if (stripped === undefined) {
delete this.raw[domain];
} else {
@ -363,7 +366,7 @@ export class ConfigService extends Disposable implements IConfigService {
const staged: ResolvedConfig = { ...this.memory };
for (const domain of domains) {
const value = sections[domain];
if (value === undefined) {
if (value === undefined || value === null) {
delete staged[domain];
} else {
staged[domain] = this.registry.validate(domain, value);
@ -376,7 +379,9 @@ export class ConfigService extends Disposable implements IConfigService {
await this.enqueueStateTransition(async () => {
const staged: ResolvedConfig = { ...this.raw };
for (const domain of domains) {
const stripped = this.stripEnv(domain, sections[domain]);
// Same `null`-means-clear encoding as `replace` (see above).
const value = sections[domain] === null ? undefined : sections[domain];
const stripped = this.stripEnv(domain, value);
if (stripped === undefined) {
delete staged[domain];
} else {

View file

@ -1,56 +0,0 @@
/**
* `hostIdentity` domain runtime identity of the embedding host.
*
* Holds process-level overrides the host product (CLI, desktop, ) injects at
* 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};
* the registered default carries no overrides, so the template renders its CLI
* defaults. Bound at App scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope';
export interface PromptIdentityOverrides {
readonly displayName?: string;
readonly replyStyleGuide?: string;
}
export interface IHostIdentity {
readonly _serviceBrand: undefined;
readonly productName?: string;
readonly replyStyleGuide?: string;
}
export const IHostIdentity: ServiceIdentifier<IHostIdentity> =
createDecorator<IHostIdentity>('hostIdentity');
export class HostIdentity implements IHostIdentity {
declare readonly _serviceBrand: undefined;
constructor(
readonly productName?: string,
readonly replyStyleGuide?: string,
) {}
}
export function hostIdentitySeed(overrides: PromptIdentityOverrides | undefined): ScopeSeed {
if (overrides === undefined) return [];
if (overrides.displayName === undefined && overrides.replyStyleGuide === undefined) return [];
return [
[
IHostIdentity as ServiceIdentifier<unknown>,
new HostIdentity(overrides.displayName, overrides.replyStyleGuide),
],
];
}
registerScopedService(
LifecycleScope.App,
IHostIdentity,
HostIdentity,
ScopeActivation.OnScopeCreated,
'hostIdentity',
);

View file

@ -46,10 +46,10 @@ import {
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { Error2 } from '#/_base/errors/errors';
import { IOAuthService } from '#/app/auth/auth';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IEventService } from '#/app/event/event';
import { ModelCatalogErrors } from '#/kosong/model/errors';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { type ModelRecord } from '#/kosong/model/model';
import {
IProviderService,
@ -90,7 +90,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
@IConfigService private readonly config: IConfigService,
@IOAuthService private readonly oauth: IOAuthService,
@IEventService private readonly events: IEventService,
@IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {}
refreshProviderModels(
@ -181,7 +181,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
removeProvider: (providerId) => this.shapeWithoutProvider(providerId),
setConfig: (patch) => this.applyRefreshPatch(patch, exclusion),
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
userAgent: this.hostRequestHeaders.headers['User-Agent'],
userAgent: this.bootstrap.args.requestHeaders['User-Agent'],
};
}

View file

@ -0,0 +1,29 @@
/**
* `kosongConfig` domain `IHostRequestHeaders` implementation.
*
* Bridges kosong's host-headers port to the host invocation args: the headers
* are the ones the host stated in `BootstrapInput.args.requestHeaders`
* (usually built through `createKimiDefaultHeaders`), exposed through
* `IBootstrapService.args`. kosong's model catalog only sees the port. Bound
* at App scope.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
export class HostRequestHeadersAdapter implements IHostRequestHeaders {
readonly headers: Readonly<Record<string, string>>;
constructor(@IBootstrapService bootstrap: IBootstrapService) {
this.headers = bootstrap.args.requestHeaders;
}
}
registerScopedService(
LifecycleScope.App,
IHostRequestHeaders,
HostRequestHeadersAdapter,
ScopeActivation.OnDemand,
'kosongConfig',
);

View file

@ -1,53 +0,0 @@
/**
* `skillCatalog` domain runtime options for skill discovery.
*
* Holds process-level runtime overrides that affect how skill roots are
* resolved. `explicitDirs` mirrors v1's SDK `skillDirs`: when present, default
* user / project discovery is skipped and the explicit directories are used as
* the user source. Bound at App scope.
*
* Composition roots set it through {@link skillCatalogRuntimeOptionsSeed}
* the registered default carries no explicit dirs.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import {
LifecycleScope,
ScopeActivation,
registerScopedService,
type ScopeSeed,
} from '#/_base/di/scope';
export interface ISkillCatalogRuntimeOptions {
readonly _serviceBrand: undefined;
readonly explicitDirs?: readonly string[];
}
export const ISkillCatalogRuntimeOptions: ServiceIdentifier<ISkillCatalogRuntimeOptions> =
createDecorator<ISkillCatalogRuntimeOptions>('skillCatalogRuntimeOptions');
export class SkillCatalogRuntimeOptions implements ISkillCatalogRuntimeOptions {
declare readonly _serviceBrand: undefined;
constructor(readonly explicitDirs?: readonly string[]) {}
}
export function skillCatalogRuntimeOptionsSeed(
explicitDirs: readonly string[] | undefined,
): ScopeSeed {
if (explicitDirs === undefined || explicitDirs.length === 0) return [];
return [
[
ISkillCatalogRuntimeOptions as ServiceIdentifier<unknown>,
new SkillCatalogRuntimeOptions(explicitDirs),
],
];
}
registerScopedService(
LifecycleScope.App,
ISkillCatalogRuntimeOptions,
SkillCatalogRuntimeOptions,
ScopeActivation.OnScopeCreated,
'skillCatalog',
);

View file

@ -17,7 +17,6 @@ import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
type MergeAllAvailableSkillsConfig,
} from './configSection';
import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions';
import { ISkillDiscovery } from './skillDiscovery';
import { userRoots } from './skillRoots';
import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource';
@ -41,7 +40,6 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IConfigService private readonly config: IConfigService,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
) {
super();
this._register(
@ -52,7 +50,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou
}
async load(): Promise<SkillContribution> {
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) {
return { skills: [] };
}
await this.config.ready;

View file

@ -9,10 +9,10 @@
* successful Kimi login), routing fetches through the Moonshot fetch service
* (`${provider.baseUrl}/fetch`); and (3) the built-in `LocalFetchURLProvider`,
* so `FetchURL` keeps working without any configuration. The first two use the
* host's Kimi identity headers (`IHostRequestHeaders`) and fall back to the
* local fetcher on failure. Reads config and the managed provider lazily on
* each `getUrlFetcher()` call so it tracks edits and login state. Bound at
* App scope.
* host's Kimi identity headers (`IBootstrapService.args.requestHeaders`) and
* fall back to the local fetcher on failure. Reads config and the managed
* provider lazily on each `getUrlFetcher()` call so it tracks edits and login
* state. Bound at App scope.
*/
import {
@ -23,8 +23,8 @@ import {
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IOAuthService } from '#/app/auth/auth';
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IProviderService } from '#/kosong/provider/provider';
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
@ -40,7 +40,7 @@ export class WebFetchService implements IWebFetchService {
constructor(
@IProviderService private readonly providers: IProviderService,
@IOAuthService private readonly oauth: IOAuthService,
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IConfigService private readonly config: IConfigService,
) {
this.localFetcher = new LocalFetchURLProvider();
@ -63,7 +63,7 @@ export class WebFetchService implements IWebFetchService {
baseUrl: fetchConfig.baseUrl,
tokenProvider,
apiKey: nonEmptyString(fetchConfig.apiKey),
defaultHeaders: { ...this.hostHeaders.headers },
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
customHeaders: fetchConfig.customHeaders,
localFallback: this.localFetcher,
});
@ -85,7 +85,7 @@ export class WebFetchService implements IWebFetchService {
return new MoonshotFetchURLProvider({
baseUrl,
tokenProvider,
defaultHeaders: { ...this.hostHeaders.headers },
defaultHeaders: { ...this.bootstrap.args.requestHeaders },
customHeaders: provider.customHeaders,
localFallback: this.localFetcher,
});

View file

@ -141,6 +141,7 @@ export * from '#/app/kosongConfig/kosongConfig';
export * from '#/app/kosongConfig/kosongConfigService';
export * from '#/kosong/model/modelOAuth';
export * from '#/app/kosongConfig/oauthTokenAdapter';
export * from '#/app/kosongConfig/hostRequestHeadersAdapter';
export * from '#/app/kosongConfig/discovery';
export * from '#/app/kosongConfig/discoveryService';
export * from '#/app/kosongConfig/errors';
@ -169,10 +170,8 @@ export {
export * from '#/workspace/workspaceAgentProfileLoader/configSection';
export { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile';
export { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths';
export * from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions';
export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader';
export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService';
export * from '#/app/hostIdentity/hostIdentity';
export * from '#/app/plugin/types';
export * from '#/app/plugin/commands';
export * from '#/app/plugin/manifest';
@ -193,7 +192,6 @@ export * from '#/agent/skill/skill';
export * from '#/agent/skill/skillService';
export * from '#/app/skillCatalog/types';
export * from '#/app/skillCatalog/configSection';
export * from '#/app/skillCatalog/skillCatalogRuntimeOptions';
export * from '#/app/skillCatalog/parser';
export * from '#/app/skillCatalog/registry';
export * from '#/app/skillCatalog/errors';

View file

@ -1,38 +1,21 @@
/**
* `kosong/model` domain host-provided default headers for outbound
* provider requests.
* `kosong/model` domain (L2) host-provided default headers for outbound
* provider requests (port contract).
*
* The host (CLI / server) builds the full Kimi identity headers (`User-Agent`
* + `X-Msh-*`) and seeds them here. Defaults to empty so non-host contexts
* (tests, embedders) send no extra headers.
* Mirrors v1's `kimiRequestHeaders`: the host (CLI / server) states its Kimi
* identity headers (`User-Agent` + `X-Msh-*`) in
* `BootstrapInput.args.requestHeaders`; the app-side adapter
* (`app/kosongConfig/hostRequestHeadersAdapter`) bridges
* `IBootstrapService.args` to this port so kosong stays a pure abstraction
* layer. `ModelCatalog` merges them per vendor the full set for vendors
* whose definition declares `hostHeaders: 'full'`, only the `User-Agent` for
* everyone else (so device identity never leaks to third-party endpoints).
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import {
LifecycleScope,
ScopeActivation,
registerScopedService,
type ScopeSeed,
} from '#/_base/di/scope';
import { createDecorator } from '#/_base/di/instantiation';
export interface IHostRequestHeaders {
readonly headers: Readonly<Record<string, string>>;
}
export const IHostRequestHeaders = createDecorator<IHostRequestHeaders>('hostRequestHeaders');
export class HostRequestHeaders implements IHostRequestHeaders {
constructor(readonly headers: Readonly<Record<string, string>> = {}) {}
}
export function hostRequestHeadersSeed(headers: Readonly<Record<string, string>>): ScopeSeed {
return [[IHostRequestHeaders as ServiceIdentifier<unknown>, new HostRequestHeaders(headers)]];
}
registerScopedService(
LifecycleScope.App,
IHostRequestHeaders,
HostRequestHeaders,
ScopeActivation.OnScopeCreated,
'model',
);

View file

@ -1,51 +0,0 @@
/**
* `workspaceAgentProfileLoader` domain runtime options for agent-file discovery.
*
* Holds process-level runtime overrides: `explicitFiles` mirrors the CLI's
* `--agent-file` individual agent Markdown files loaded as the highest-
* priority `explicit` source. Composition roots set it through
* {@link agentCatalogRuntimeOptionsSeed}; the registered default carries no
* explicit files. Bound at App scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import {
LifecycleScope,
ScopeActivation,
registerScopedService,
type ScopeSeed,
} from '#/_base/di/scope';
export interface IAgentCatalogRuntimeOptions {
readonly _serviceBrand: undefined;
readonly explicitFiles?: readonly string[];
}
export const IAgentCatalogRuntimeOptions: ServiceIdentifier<IAgentCatalogRuntimeOptions> =
createDecorator<IAgentCatalogRuntimeOptions>('agentCatalogRuntimeOptions');
export class AgentCatalogRuntimeOptions implements IAgentCatalogRuntimeOptions {
declare readonly _serviceBrand: undefined;
constructor(readonly explicitFiles?: readonly string[]) {}
}
export function agentCatalogRuntimeOptionsSeed(
explicitFiles: readonly string[] | undefined,
): ScopeSeed {
if (explicitFiles === undefined || explicitFiles.length === 0) return [];
return [
[
IAgentCatalogRuntimeOptions as ServiceIdentifier<unknown>,
new AgentCatalogRuntimeOptions(explicitFiles),
],
];
}
registerScopedService(
LifecycleScope.App,
IAgentCatalogRuntimeOptions,
AgentCatalogRuntimeOptions,
ScopeActivation.OnScopeCreated,
'workspaceAgentProfileLoader',
);

View file

@ -10,7 +10,6 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
import { ILogService } from '#/_base/log/log';
import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry';
import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions';
import { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile';
import { AgentProfileLoaderBase } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileLoader';
import { agentProfileFromFile } from '#/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile';
@ -37,7 +36,6 @@ export class ExplicitAgentProfileLoaderService
protected override readonly fatal = true;
constructor(
@IAgentCatalogRuntimeOptions private readonly runtimeOptions: IAgentCatalogRuntimeOptions,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IHostFileSystem private readonly fs: IHostFileSystem,
@ -54,7 +52,7 @@ export class ExplicitAgentProfileLoaderService
}
protected async load(): Promise<AgentProfileContribution> {
const files = this.runtimeOptions.explicitFiles ?? [];
const files = this.bootstrap.args.agentFiles ?? [];
const profiles: AgentProfile[] = [];
for (const file of files) {
const filePath = resolveAgentPath(file, this.workspace.cwd, this.bootstrap.osHomeDir);

View file

@ -1,18 +1,18 @@
/**
* `workspaceSkillCatalog` domain explicit `ISkillSource` producer.
*
* Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this
* source contributes those directories as the user source, resolving relative
* paths against the workspace root. When no explicit dirs are configured,
* it yields nothing so default user / project discovery remains active. Bound
* at Workspace scope so every session of the handler shares one scan.
* Mirrors v1 SDK `skillDirs`: when the host invocation args provide
* `skillDirs`, this source contributes those directories as the user source,
* resolving relative paths against the workspace root. When no explicit dirs
* are configured, it yields nothing so default user / project discovery
* remains active. Bound at Workspace scope so every session of the handler
* shares one scan.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { configuredRoots } from '#/app/skillCatalog/skillRoots';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import {
SKILL_SOURCE_PRIORITY,
@ -36,13 +36,12 @@ export class ExplicitFileSkillSource implements IExplicitFileSkillSource {
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {}
async load(): Promise<SkillContribution> {
const explicitDirs = this.runtimeOptions.explicitDirs ?? [];
const explicitDirs = this.bootstrap.args.skillDirs ?? [];
if (explicitDirs.length === 0) {
return { skills: [] };
}

View file

@ -19,11 +19,11 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/
import { TimeoutTimer } from '#/_base/utils/timer';
import { subtreeWatchFilter } from '#/_base/utils/paths';
import { IConfigService } from '#/app/config/config';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
type MergeAllAvailableSkillsConfig,
} from '#/app/skillCatalog/configSection';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { projectRoots, projectSkillRootCandidates } from '#/app/skillCatalog/skillRoots';
import {
@ -59,7 +59,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
@IConfigService private readonly config: IConfigService,
@ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IHostFsWatchService private readonly fsWatch: IHostFsWatchService,
) {
super();
@ -73,7 +73,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
async load(): Promise<SkillContribution> {
await this.watchReady;
if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) {
if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) {
return { skills: [] };
}
await this.config.ready;

View file

@ -21,7 +21,6 @@ import { IAgentStateService } from '#/agent/state/agentState';
import { AgentStateService } from '#/agent/state/agentStateService';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IHostIdentity } from '#/app/hostIdentity/hostIdentity';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
@ -213,7 +212,6 @@ function buildHost(key: string): {
host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub());
host.stub(IHostEnvironment, stubUnused());
host.stub(IHostFileSystem, stubUnused());
host.stub(IHostIdentity, stubUnused());
host.stub(IBootstrapService, stubUnused());
host.stub(ISessionContext, createSessionContextStub());
host.stub(ISessionWorkspaceContext, stubUnused());

View file

@ -32,7 +32,7 @@ import { IConfigService } from '#/app/config/config';
import { ConfigRegistry } from '#/app/config/configService';
import { type DomainEvent, IEventService } from '#/app/event/event';
import { ILogService } from '#/_base/log/log';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IModelService, type ModelRecord } from '#/kosong/model/model';
import { MODELS_SECTION } from '#/app/kosongConfig/configSection';
import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider';
@ -835,10 +835,12 @@ describe('WebSearchProviderService', () => {
resolveTokenProvider:
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
});
reg.definePartialInstance(IHostRequestHeaders, {
headers: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
reg.definePartialInstance(IBootstrapService, {
args: {
requestHeaders: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
},
},
});
reg.definePartialInstance(IConfigService, {

View file

@ -9,6 +9,8 @@
import type { ServiceRegistration } from '#/_base/di/test';
import {
IBootstrapService,
resolveHostArgs,
type HostArgsInput,
type PersistenceScopeName,
} from '#/app/bootstrap/bootstrap';
@ -18,7 +20,11 @@ export const stubClientIdentity = {
platform: 'test_platform',
} as const;
export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService {
export function stubBootstrap(
homeDir = '/tmp/kimi-home',
env: NodeJS.ProcessEnv = {},
args: HostArgsInput = {},
): IBootstrapService {
const scopes: Record<PersistenceScopeName, string> = {
config: '',
sessions: 'sessions',
@ -39,6 +45,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv
configPath: `${homeDir}/config.toml`,
configKey: 'config.toml',
clientIdentity: stubClientIdentity,
args: resolveHostArgs(args),
sessionsDir: `${homeDir}/sessions`,
blobsDir: `${homeDir}/blobs`,
storeDir: `${homeDir}/store`,

View file

@ -1960,6 +1960,31 @@ describe('ConfigService replaceSections', () => {
disposables.dispose();
});
it('treats null as clear — the wire encoding JSON transports use for undefined', async () => {
const { config, disposables, store } = await createSectionsConfig();
const setSpy = vi.spyOn(store, 'set');
await config.replaceSections({
[DEFAULT_MODEL_SECTION]: null,
[PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } },
});
expect(setSpy).toHaveBeenCalledTimes(1);
expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined();
expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined();
expect(config.get<Record<string, unknown>>(PROVIDERS_SECTION)).toEqual({
acme: { type: 'openai', apiKey: 'sk-acme-2' },
});
// `replace(domain, null)` clears too, so JSON transports behave
// identically to in-process `replace(domain, undefined)` callers.
await config.replace(DEFAULT_MODEL_SECTION, 'acme/m1');
await config.replace(DEFAULT_MODEL_SECTION, null);
expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined();
disposables.dispose();
});
it('fires change events only after all domains have taken effect', async () => {
const { config, disposables } = await createSectionsConfig();
const domains: string[] = [];

View file

@ -28,6 +28,7 @@ import { createScopedTestHost } from '#/_base/di/test';
import { isError2 } from '#/_base/errors/errors';
import { ILogService, type LogPayload } from '#/_base/log/log';
import { IOAuthService } from '#/app/auth/auth';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { ConfigRegistry } from '#/app/config/configService';
import { IEventService } from '#/app/event/event';
@ -37,7 +38,6 @@ import { MODEL_CATALOG_SECTION } from '#/app/kosongConfig/configSection';
import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig';
import '#/app/kosongConfig/kosongConfigService';
import '#/kosong/model/errors';
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import {
IModelService,
type ModelRecord,
@ -52,6 +52,7 @@ import '#/kosong/provider/providers/kimi/kimi.contrib';
import '#/kosong/provider/providers/standard.contrib';
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs';
import { stubBootstrap } from '../bootstrap/stubs';
function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } {
const published: Array<{ type: string; payload: unknown }> = [];
@ -100,7 +101,10 @@ async function createHost(
[IOAuthService, oauth],
[IEventService, events],
[ILogService, stubLogService()],
[IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })],
[
IBootstrapService,
stubBootstrap('/tmp/kimi-home', {}, { requestHeaders: { 'User-Agent': 'kimi-test/1.0' } }),
],
]);
const providers = host.app.accessor.get(IProviderService);
const models = host.app.accessor.get(IModelService);

View file

@ -12,8 +12,8 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IOAuthService } from '#/app/auth/auth';
import { SERVICES_SECTION, type ServicesConfig } from '#/app/auth/configSection';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
import { LocalFetchURLProvider } from '#/app/web/providers/local-fetch-url';
import { MoonshotFetchURLProvider } from '#/app/web/providers/moonshot-fetch-url';
@ -47,10 +47,12 @@ describe('WebFetchService', () => {
resolveTokenProvider:
resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
});
reg.definePartialInstance(IHostRequestHeaders, {
headers: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
reg.definePartialInstance(IBootstrapService, {
args: {
requestHeaders: {
'User-Agent': 'kimi-code-cli/test',
'X-Msh-Device-Id': 'device-test',
},
},
});
reg.definePartialInstance(IConfigService, {

View file

@ -52,7 +52,7 @@ import {
} from '#/kosong/model/catalog';
import { ModelCatalog } from '#/kosong/model/catalogService';
import '#/kosong/model/errors';
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/model/model';
import '#/kosong/model/modelService';
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
@ -75,7 +75,7 @@ function createHost(
const host = createScopedTestHost([
[IConfigService, config],
[IModelOAuthTokens, oauthTokens],
[IHostRequestHeaders, new HostRequestHeaders(HOST_HEADERS)],
[IHostRequestHeaders, { headers: HOST_HEADERS }],
]);
// Kosong's registries are pure in-memory stores now (persistence lives in
// the app/kosongConfig bridge): seed them from the fixture sections.
@ -807,7 +807,7 @@ describe('ModelCatalog ping', () => {
models,
stubModelOAuthTokens(),
registry,
new HostRequestHeaders({}),
{ headers: {} },
);
const result = await catalog.ping('k1');
expect(result).toMatchObject({ ok: true, text: 'pong', finishReason: 'completed' });

View file

@ -136,4 +136,13 @@ describe('check-import-boundaries', () => {
);
expect(violations).toHaveLength(0);
});
it('resolves the package self-reference as an intra-v2 import', () => {
const violations = checkSource(
`import { Foo } from '@moonshot-ai/agent-core-v2/kosong/provider/provider';`,
atKosong('protocol', 'protocol.ts'),
);
expect(violations).toHaveLength(1);
expect(violations[0]?.message).toMatch(/kosong layer violation/);
});
});

View file

@ -19,7 +19,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Emitter, Event } from '#/_base/event';
import type { ILogService } from '#/_base/log/log';
import type { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions';
import { EXTRA_AGENT_DIRS_SECTION } from '#/workspace/workspaceAgentProfileLoader/configSection';
import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService';
import type { PluginAgentRoot } from '#/app/plugin/types';
@ -252,15 +251,11 @@ function makeStack(fixture: Fixture, opts?: StackOptions) {
const config = configStub();
if (opts?.extraAgentDirs !== undefined) config.setExtraAgentDirs(opts.extraAgentDirs);
const bootstrap: IBootstrapService = {
...stubBootstrap(fixture.homeDir),
...stubBootstrap(fixture.homeDir, {}, { agentFiles: opts?.explicitFiles }),
osHomeDir: fixture.osHomeDir,
};
const hostFs = opts?.hostFs ?? new HostFileSystem();
const workspaceContext = workspaceContextStub(fixture.workDir);
const runtimeOptions = {
_serviceBrand: undefined,
explicitFiles: opts?.explicitFiles,
} as unknown as IAgentCatalogRuntimeOptions;
const registry = new AgentProfileRegistryService();
const builtinLoader = new BuiltinAgentProfileLoaderService(registry);
@ -298,7 +293,6 @@ function makeStack(fixture: Fixture, opts?: StackOptions) {
registry,
);
const explicitLoader = new ExplicitAgentProfileLoaderService(
runtimeOptions,
workspaceContext,
bootstrap,
hostFs,

View file

@ -32,12 +32,11 @@ import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileReg
import { AgentProfileRegistryService } from '#/app/agentProfileCatalog/agentProfileRegistryService';
import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader';
import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService';
import { IAgentCatalogRuntimeOptions } from '#/workspace/workspaceAgentProfileLoader/agentCatalogRuntimeOptions';
import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader';
import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService';
import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader';
import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IBootstrapService, resolveHostArgs } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { IEventService } from '#/app/event/event';
@ -47,7 +46,6 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource';
import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource';
@ -278,6 +276,7 @@ describe('workspace resource sharing (handler chain)', () => {
_serviceBrand: undefined,
homeDir,
osHomeDir: homeDir,
args: resolveHostArgs(undefined),
scope: (name: string) => name,
} as unknown as IBootstrapService),
stubPair(IHostEnvironment, {
@ -297,12 +296,6 @@ describe('workspace resource sharing (handler chain)', () => {
} as unknown as IConfigService),
stubPair(ITelemetryService, noopTelemetryService),
stubPair(ISkillDiscovery, discovery),
stubPair(ISkillCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions),
stubPair(IAgentCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as IAgentCatalogRuntimeOptions),
stubPair(IPluginService, pluginStub()),
stubPair(IWorkspaceService, workspaceCatalogStub()),
stubPair(ISessionIndex, {

View file

@ -37,7 +37,6 @@ import {
EXTRA_SKILL_DIRS_SECTION,
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
} from '#/app/skillCatalog/configSection';
import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions';
import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource';
import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource';
import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery';
@ -168,15 +167,10 @@ function makeHost(
pluginReloadEmitter?: Emitter<ReloadSummary>,
) {
const config = configStub();
const runtimeOptions = {
_serviceBrand: undefined,
explicitDirs,
} as unknown as ISkillCatalogRuntimeOptions;
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IBootstrapService, stubBootstrap('/home', {}, { skillDirs: explicitDirs })),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -383,15 +377,11 @@ describe('WorkspaceSkillCatalogService', () => {
} as unknown as IConfigService;
const store = new InMemorySkillDiscovery();
store.setExtraSkills([stubSkill('extra-only', { description: 'from extra', source: 'extra' })]);
const runtimeOptions = {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions;
const ws = workspaceContextStub('/work');
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub()),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -423,15 +413,11 @@ describe('WorkspaceSkillCatalogService', () => {
}
const store = new CountingDiscovery();
const config = configStub();
const runtimeOptions = {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions;
const ws = workspaceContextStub('/work');
const host = createScopedTestHost([
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, config),
stubPair(ISkillCatalogRuntimeOptions, runtimeOptions),
stubPair(IPluginService, pluginStub()),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -646,9 +632,6 @@ describe('WorkspaceSkillCatalogService', () => {
stubPair(ISkillDiscovery, new InMemorySkillDiscovery()),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(ISkillCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions),
stubPair(IPluginService, pluginStub()),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -706,9 +689,6 @@ describe('WorkspaceSkillCatalogService', () => {
stubPair(ISkillDiscovery, new InMemorySkillDiscovery()),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(ISkillCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions),
stubPair(IPluginService, pluginService),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -765,9 +745,6 @@ describe('WorkspaceSkillCatalogService', () => {
stubPair(ISkillDiscovery, store),
stubPair(IBootstrapService, stubBootstrap(homeDir)),
stubPair(IConfigService, configStub()),
stubPair(ISkillCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions),
stubPair(IProviderService, stubProviderService()),
stubPair(IHostFsWatchService, fsWatchStub()),
]);
@ -821,9 +798,6 @@ describe('WorkspaceSkillCatalogService', () => {
const host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(ISkillCatalogRuntimeOptions, {
_serviceBrand: undefined,
} as unknown as ISkillCatalogRuntimeOptions),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),

View file

@ -81,7 +81,6 @@ import {
ISessionIndex,
ISessionMetadata,
ISessionSkillCatalog,
ISkillCatalogRuntimeOptions,
ISkillDiscovery,
IWorkspaceService,
InMemorySkillCatalog,
@ -342,11 +341,10 @@ async function listWorkspaceSkillsForRoot(
const plugins = core.accessor.get(IPluginService);
const config = core.accessor.get(IConfigService);
await config.ready;
const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions);
const extraSkillDirs = config.get<ExtraSkillDirsConfig>(EXTRA_SKILL_DIRS_SECTION) ?? [];
const mergeAllAvailableSkills =
config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
const explicitDirs = runtimeOptions.explicitDirs ?? [];
const explicitDirs = bootstrap.args.skillDirs ?? [];
const useExplicitDirs = explicitDirs.length > 0;
const rootOptions = { mergeAllAvailableSkills };

View file

@ -9,8 +9,6 @@
import {
bootstrap,
hostIdentitySeed,
hostRequestHeadersSeed,
IConfigService,
IProviderDiscoveryService,
IWorkspaceService,
@ -18,7 +16,6 @@ import {
resolveConfigPath,
resolveKimiHome,
resolveLoggingConfig,
skillCatalogRuntimeOptionsSeed,
type Scope,
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';
@ -247,18 +244,17 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
homeDir,
configPath,
clientIdentity: opts.hostIdentity,
args: {
// Default host identity headers derived from `hostIdentity`: outbound
// requests (model, WebSearch, registry refresh) carry the host
// product's User-Agent + X-Msh-* set.
requestHeaders: createKimiDefaultHeaders({ homeDir, ...opts.hostIdentity }),
skillDirs: opts.skillDirs,
displayName: opts.hostIdentity.displayName,
replyStyleGuide: opts.hostIdentity.replyStyleGuide,
},
},
[
...logSeed(logging),
// Default host identity headers derived from `hostIdentity`: outbound
// requests (model, WebSearch, registry refresh) carry the host product's
// User-Agent + X-Msh-* set. A host can still override individual headers
// through `opts.seeds`, which are applied last (last seed wins).
...hostRequestHeadersSeed(createKimiDefaultHeaders({ homeDir, ...opts.hostIdentity })),
...skillCatalogRuntimeOptionsSeed(opts.skillDirs),
...hostIdentitySeed(opts.hostIdentity),
...(opts.seeds ?? []),
],
[...logSeed(logging), ...(opts.seeds ?? [])],
);
// Attach the cloud telemetry appender BEFORE any session is created:

View file

@ -13,12 +13,10 @@ import { pino } from 'pino';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
hostRequestHeadersSeed,
IBootstrapService,
IFileSystemStorageService,
IHostRequestHeaders,
InMemoryStorageService,
ISkillCatalogRuntimeOptions,
IOAuthToolkit,
ITelemetryService,
noopTelemetryService,
@ -151,7 +149,7 @@ describe('server-v2 boot', () => {
expect(defaults.headers['X-Msh-Platform']).toBe('test_platform');
// Restart on the same homeDir with a host-provided seed; it must win over
// the default (the CLI passes full Kimi identity headers this way).
// the default (a host can always re-seed the port with its own instance).
await server.close();
server = undefined;
server = await startServer({
@ -160,7 +158,7 @@ describe('server-v2 boot', () => {
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: hostRequestHeadersSeed({ 'User-Agent': 'custom-host/9.9' }),
seeds: [[IHostRequestHeaders, { headers: { 'User-Agent': 'custom-host/9.9' } }]],
});
const overridden = server.core.accessor.get(IHostRequestHeaders);
expect(overridden.headers['User-Agent']).toBe('custom-host/9.9');
@ -176,11 +174,11 @@ describe('server-v2 boot', () => {
logLevel: 'silent',
skillDirs: ['/skills/explicit'],
});
expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toEqual([
expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toEqual([
'/skills/explicit',
]);
// Without skillDirs the registered default carries no explicit dirs.
// Without skillDirs the resolved args carry no explicit dirs.
await server.close();
server = undefined;
server = await startServer({
@ -190,7 +188,7 @@ describe('server-v2 boot', () => {
homeDir: home,
logLevel: 'silent',
});
expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined();
expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toBeUndefined();
});
it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => {

View file

@ -28,7 +28,6 @@ import { join } from 'node:path';
import {
IAgentLifecycleService,
getLiveSessionById,
ISkillCatalogRuntimeOptions,
} from '@moonshot-ai/agent-core-v2';
import {
activateSkillResultSchema,
@ -313,7 +312,7 @@ describe('server-v2 /api/v1 skills', () => {
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: [[ISkillCatalogRuntimeOptions, { _serviceBrand: undefined, explicitDirs: [explicitDir] }]] as never,
skillDirs: [explicitDir],
});
base = `http://127.0.0.1:${server.port}`;

View file

@ -35,6 +35,10 @@ export const configContract = {
input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]),
output: noResult,
},
replaceSections: {
input: z.tuple([z.record(z.string(), z.unknown()), configTargetSchema.optional()]),
output: noResult,
},
reload: { input: z.tuple([]), output: noResult },
diagnostics: { input: z.tuple([]), output: z.array(configDiagnosticSchema) },
} satisfies ServiceContract;

View file

@ -128,6 +128,15 @@ export interface GlobalConfigFacade {
value: unknown;
target?: ConfigTargetLiteral;
}): Promise<void>;
/**
* Replace several domains in ONE atomic write (the engine's
* `IConfigService.replaceSections`): a domain mapped to `undefined` is
* cleared, domains absent from `sections` are left untouched.
*/
replaceSections(input: {
sections: Record<string, unknown>;
target?: ConfigTargetLiteral;
}): Promise<void>;
reload(): Promise<void>;
diagnostics(): Promise<readonly ConfigDiagnostic[]>;
}
@ -306,7 +315,19 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr
set: ({ domain, patch, target }) =>
call('configService', 'set', [domain, patch, target]) as Promise<void>,
replace: ({ domain, value, target }) =>
call('configService', 'replace', [domain, value, target]) as Promise<void>,
// `null` is the wire encoding of "clear this domain" — JSON
// round-trips cannot carry `undefined` (see IConfigService.replace).
call('configService', 'replace', [domain, value === undefined ? null : value, target]) as Promise<void>,
replaceSections: ({ sections, target }) =>
call('configService', 'replaceSections', [
Object.fromEntries(
Object.entries(sections).map(([domain, value]) => [
domain,
value === undefined ? null : value,
]),
),
target,
]) as Promise<void>,
reload: () => call('configService', 'reload', []) as Promise<void>,
diagnostics: () =>
call('configService', 'diagnostics', []) as Promise<readonly ConfigDiagnostic[]>,

View file

@ -139,6 +139,39 @@ export function defineKlientConformance(
expect(Array.isArray(await target.klient.global.config.diagnostics())).toBe(true);
});
it('config replaceSections writes several domains and clears undefined ones', async () => {
const config = target.klient.global.config;
const beforeProviders = await config.inspect<Record<string, unknown>>('providers');
const beforeModels = await config.inspect<Record<string, unknown>>('models');
try {
await config.replaceSections({
sections: {
providers: {
...beforeProviders.userValue,
'conf-provider': { type: 'openai', baseUrl: 'http://127.0.0.1:1', apiKey: 'k' },
},
models: {
...beforeModels.userValue,
'conf-provider/m1': { provider: 'conf-provider', model: 'm1', maxContextSize: 100 },
},
defaultModel: 'conf-provider/m1',
},
});
expect((await config.inspect<string>('defaultModel')).userValue).toBe('conf-provider/m1');
// A domain mapped to `undefined` is cleared; domains absent from the
// sections record are left untouched.
await config.replaceSections({ sections: { defaultModel: undefined } });
expect((await config.inspect<string>('defaultModel')).userValue).toBeUndefined();
const providers = await config.inspect<Record<string, unknown>>('providers');
expect(providers.userValue?.['conf-provider']).toBeDefined();
} finally {
await config.replaceSections({
sections: { providers: beforeProviders.userValue, models: beforeModels.userValue },
});
}
});
it('hostFs.home() returns the host home and recent roots', async () => {
const home = await target.klient.global.hostFs.home();
expect(home.home.length).toBeGreaterThan(0);

View file

@ -20,6 +20,7 @@ export {
export { SDKRpcClientBase } from '#/rpc';
export { KimiForCodingProvider } from '#/kimi-code-model-provider';
export type { KimiForCodingProviderOptions } from '#/kimi-code-model-provider';
export { removeProviderFromConfig } from '#/v2/config-mapper';
export {
applyCatalogProvider,

View file

@ -339,6 +339,24 @@ export class KimiHarness {
return this.rpc.removeProvider(providerId);
}
/**
* Whether several config sections can be persisted as ONE atomic write
* (see {@link replaceConfigSections}). False on the v1 harness.
*/
supportsAtomicSectionReplace(): boolean {
return this.rpc.supportsAtomicSectionReplace();
}
/**
* Replace several top-level config sections in ONE atomic write: a section
* mapped to `undefined` is cleared, absent sections are left untouched.
* Replace semantics (unlike {@link setConfig}'s deep-merge), so staged
* removals are expressed by the written record itself.
*/
async replaceConfigSections(sections: Record<string, unknown>): Promise<void> {
return this.rpc.replaceConfigSections(sections);
}
/** User-global MCP entries from `<KIMI_CODE_HOME>/mcp.json` only. */
async listMcpServers(): Promise<readonly McpServerConfig[]> {
return this.rpc.listGlobalMcpServers();

View file

@ -294,6 +294,29 @@ export abstract class SDKRpcClientBase {
return rpc.removeKimiProvider({ providerId });
}
/**
* Whether this client can persist several config sections as ONE atomic
* write (see {@link replaceConfigSections}). v1 cannot its config writes
* are whole-document merges so the default is false.
*/
supportsAtomicSectionReplace(): boolean {
return false;
}
/**
* Replace several top-level config sections in ONE atomic write: a section
* mapped to `undefined` is cleared, sections absent from the record are
* left untouched. Unlike {@link setConfig} (a deep-merge that cannot
* delete keys), this has replace semantics, so a staged removal can be
* expressed by the written record itself.
*/
replaceConfigSections(_sections: Record<string, unknown>): Promise<void> {
throw new KimiError(
ErrorCodes.NOT_IMPLEMENTED,
'This SDK client does not support atomic config section replacement.',
);
}
async listGlobalMcpServers(): Promise<readonly McpServerConfig[]> {
const rpc = await this.getRpc();
return rpc.listGlobalMcpServers({});

View file

@ -192,11 +192,9 @@ import {
ISessionSecondaryModelWarningService,
ISessionSkillCatalog,
ISessionWorkspaceContext,
ISkillCatalogRuntimeOptions,
ISkillDiscovery,
ITelemetryService,
IWorkspaceAliases,
hostRequestHeadersSeed,
IWorkspaceDirs,
ISessionLifecycleService,
IWorkspaceLifecycleService,
@ -222,7 +220,6 @@ import {
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
skillCatalogRuntimeOptionsSeed,
summarizeSkill,
userRoots,
type IAgentScopeHandle,
@ -328,8 +325,7 @@ export interface SDKRpcClientV2Options {
* Explicit skill directories for this process (v1's SDK `skillDirs` /
* the CLI's `--skills-dir`): when non-empty, default user / project skill
* discovery is skipped and these directories serve as the user skill
* source. Seeded into the engine's app-scope
* `ISkillCatalogRuntimeOptions`.
* source. Passed into the engine through `BootstrapInput.args.skillDirs`.
*/
readonly skillDirs?: readonly string[];
readonly telemetry?: TelemetryClient;
@ -442,19 +438,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
homeDir: this.homeDir,
configPath: this.configPath,
clientIdentity: identity,
args: {
// Host identity headers for the engine's outbound requests (model,
// WebSearch, registry refresh). Without them the managed vendors go
// out with the SDK's default User-Agent and no X-Msh-* at all.
requestHeaders: createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }),
// `--skills-dir` (v1 parity): explicit skill dirs replace default
// user / project discovery for every session this client hosts.
skillDirs: options.skillDirs,
},
},
[
...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })),
// Host identity headers for the engine's outbound requests (model,
// WebSearch, registry refresh). Without this seed the managed vendors
// go out with the SDK's default User-Agent and no X-Msh-* at all.
...hostRequestHeadersSeed(
createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }),
),
// `--skills-dir` (v1 parity): explicit skill dirs replace default
// user / project discovery for every session this client hosts.
...skillCatalogRuntimeOptionsSeed(options.skillDirs),
],
[...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))],
);
this.app = app;
this.klient = createKlient({ scope: app });
@ -562,7 +556,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
override async listWorkspaceSkills(workDir: string): Promise<readonly SkillSummary[]> {
const bootstrapService = this.engineAccessor.get(IBootstrapService);
const discovery = this.engineAccessor.get(ISkillDiscovery);
const explicitDirs = this.engineAccessor.get(ISkillCatalogRuntimeOptions).explicitDirs ?? [];
const explicitDirs = bootstrapService.args.skillDirs ?? [];
const roots =
explicitDirs.length > 0
? await configuredRoots(explicitDirs, workDir, bootstrapService.osHomeDir, 'user')
@ -671,9 +665,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
/**
* v1's removal cascades: the provider entry, every model pointing at it,
* and the default pointers when they dangle. The engine's own
* `kosong.removeProvider` only clears the default-provider pointer, so
* the full v1 cascade is computed from the user-layer values and applied
* through the config facade (see `planProviderRemoval`).
* `kosong.removeProvider` only clears the default-provider pointer, so the
* full v1 cascade is computed from the user-layer values (see
* `planProviderRemoval`) and persisted as ONE atomic multi-section replace
* the same single-write shape as v1's `removeKimiProvider`, so a process
* exit can never leave the file in a halfway-cascaded state.
*/
override async removeProvider(providerId: string): Promise<KimiConfig> {
await this.configReady;
@ -690,17 +686,29 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
defaultProvider: defaultProvider.userValue,
providerId,
});
await this.klient.global.config.replace({ domain: 'providers', value: plan.providers });
await this.klient.global.config.replace({ domain: 'models', value: plan.models });
const sections: Record<string, unknown> = {
providers: plan.providers,
models: plan.models,
};
if (plan.clearDefaultModel) {
await this.klient.global.config.replace({ domain: 'defaultModel', value: undefined });
sections['defaultModel'] = undefined;
}
if (plan.clearDefaultProvider) {
await this.klient.global.config.replace({ domain: 'defaultProvider', value: undefined });
sections['defaultProvider'] = undefined;
}
await this.klient.global.config.replaceSections({ sections });
return this.getConfig();
}
override supportsAtomicSectionReplace(): boolean {
return true;
}
override async replaceConfigSections(sections: Record<string, unknown>): Promise<void> {
await this.configReady;
await this.klient.global.config.replaceSections({ sections });
}
override async listPlugins(): Promise<readonly PluginSummary[]> {
return this.klient.global.plugins.list();
}

View file

@ -125,3 +125,28 @@ export function planProviderRemoval(input: {
clearDefaultProvider: input.defaultProvider === input.providerId,
};
}
/**
* Apply the v1 remove-provider cascade to a whole `KimiConfig` in memory (no
* persistence): drop the provider entry, every model pointing at it, and the
* default pointers when they dangle. Hosts that stage a removal and fold it
* into a later atomic write (instead of persisting it immediately) build on
* this the same role the v2 engine's `shapeWithoutProvider` plays for its
* own refresh path.
*/
export function removeProviderFromConfig(config: KimiConfig, providerId: string): KimiConfig {
const plan = planProviderRemoval({
providers: config.providers as Record<string, unknown> | undefined,
models: config.models as Record<string, Record<string, unknown>> | undefined,
defaultModel: config.defaultModel,
defaultProvider: config.defaultProvider,
providerId,
});
return {
...config,
providers: plan.providers as KimiConfig['providers'],
models: plan.models as KimiConfig['models'],
defaultModel: plan.clearDefaultModel ? undefined : config.defaultModel,
defaultProvider: plan.clearDefaultProvider ? undefined : config.defaultProvider,
};
}

View file

@ -23,8 +23,10 @@ import {
ErrorCodes,
KimiError,
KimiHarness,
SDKRpcClientV2,
type Event,
removeProviderFromConfig,
SDKRpcClientV2,
type KimiConfig,
} from '#/index';
import { foldAgentWireReplay } from '#/v2/resume-replay';
import {
@ -511,6 +513,60 @@ key = "${titleOAuthRef.key}"
}
});
it('persists removeProvider as one atomic cascade (providers, models, defaults)', async () => {
const { harness } = await makeHarness();
try {
await harness.setConfig({
providers: {
a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' },
b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' },
},
models: {
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
},
defaultModel: 'b/m1',
defaultProvider: 'b',
});
const next = await harness.removeProvider('b');
expect(next.providers['b']).toBeUndefined();
expect(next.providers['a']).toBeDefined();
expect(next.models?.['b/m1']).toBeUndefined();
expect(next.models?.['a/m1']).toBeDefined();
expect(next.defaultModel).toBeUndefined();
expect(next.defaultProvider).toBeUndefined();
// A fresh read from disk sees the same state — the cascade landed as a
// single atomic write, never a halfway-removed intermediate.
const reread = await harness.getConfig({ reload: true });
expect(reread.providers['b']).toBeUndefined();
expect(reread.models?.['b/m1']).toBeUndefined();
expect(reread.defaultModel).toBeUndefined();
expect(reread.defaultProvider).toBeUndefined();
} finally {
await harness.close();
}
});
it('replaces config sections atomically and clears undefined sections', async () => {
const { harness } = await makeHarness();
try {
expect(harness.supportsAtomicSectionReplace()).toBe(true);
await harness.setConfig({
providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' } },
models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 } },
defaultModel: 'a/m1',
});
await harness.replaceConfigSections({ defaultModel: undefined });
const next = await harness.getConfig({ reload: true });
expect(next.defaultModel).toBeUndefined();
// Sections absent from the write stay untouched.
expect(next.providers['a']).toBeDefined();
expect(next.models?.['a/m1']).toBeDefined();
} finally {
await harness.close();
}
});
it('fails loudly with not_implemented for methods not yet migrated', async () => {
const { harness } = await makeHarness();
try {
@ -699,8 +755,58 @@ describe('SDKRpcClientV2 engine telemetry', () => {
});
});
async function writeSkill(dir: string, name: string): Promise<void> {
await mkdir(dir, { recursive: true });
describe('removeProviderFromConfig', () => {
it('drops the provider, its models and dangling default pointers without mutating the input', () => {
const config = {
providers: {
a: { type: 'openai', baseUrl: 'https://a.example.test/v1' },
b: { type: 'openai', baseUrl: 'https://b.example.test/v1' },
},
models: {
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
'my-b': { provider: 'b', model: 'm1', maxContextSize: 100 },
},
defaultModel: 'my-b',
defaultProvider: 'b',
} as unknown as KimiConfig;
const next = removeProviderFromConfig(config, 'b');
expect(Object.keys(next.providers)).toEqual(['a']);
expect(Object.keys(next.models ?? {})).toEqual(['a/m1']);
expect(next.defaultModel).toBeUndefined();
expect(next.defaultProvider).toBeUndefined();
// The input config is left untouched (the staging host threads the copy).
expect(config.providers['b']).toBeDefined();
expect(config.models?.['b/m1']).toBeDefined();
expect(config.defaultModel).toBe('my-b');
});
it('keeps the default pointers when they do not dangle', () => {
const config = {
providers: {
a: { type: 'openai' },
b: { type: 'openai' },
},
models: {
'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 },
'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 },
},
defaultModel: 'a/m1',
defaultProvider: 'a',
} as unknown as KimiConfig;
const next = removeProviderFromConfig(config, 'b');
expect(Object.keys(next.providers)).toEqual(['a']);
expect(Object.keys(next.models ?? {})).toEqual(['a/m1']);
expect(next.defaultModel).toBe('a/m1');
expect(next.defaultProvider).toBe('a');
});
});
async function writeSkill(dir: string, name: string): Promise<void> { await mkdir(dir, { recursive: true });
await writeFile(
join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: Skill ${name} for the escape-hatch test\n---\n\nBody of ${name}.\n`,