mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-10 17:29:18 +00:00
* feat(agent-core-v2): add the L3 unit layer and the Feature seam - introduce the L3 Service/Fiber unit layer: the Service base class with this.provide/effect/on/get/ref capabilities, the fiber runtime with thenable FiberHandles, collection contribution points, and the per-scope-kind ScopeUnits materialization fold - provide each scope's static registration batch as one atomic provideAll cascade transaction (waiting-area activation, sticky Failed on construction error) - add the DI unit inspection surface: App-scope debug ledger / dependency graph / cascade history services and the kimi-inspect DI view - add the Feature unit seam (IFeatureManager + feature assembly), port plan mode onto it, and add the contributed-command seam (agent-command domain + node-sdk RPC types) - remove the legacy dep-graph tooling - apply the header-only comment convention across src and test: strip non-header narration, keep the file header, tooling pragmas, and NOTE comments * feat(kap-server): gate the event.di.* debug feed to kimi-inspect connections - add an opt-in target set in SessionEventBroadcaster; the global fan-out now skips event.di.* frames for connections that never opted in, so kimi-web and other clients no longer receive the high-churn DI feed - WsConnectionV1 opts a connection in when client_hello carries client_id 'kimi-inspect'; removeGlobalTarget drops the opt-in on close - temporary gate until a client-declared event-type whitelist lands * chore(agent-core-v2): fix oxlint errors in the DI unit layer - build the live-ref container chain without aliasing this (no-this-alias) - snapshot the materialized map with Array.from and document why the copy is required (no-useless-spread) * test(klient): use string scope kinds in the lifecycle handle fakes The engine's LifecycleScope is a string enum now; the facade test doubles still returned the old numeric kinds and failed the handleWireSchema output validation. * build(nix): update the pnpmDeps fetch hash
789 lines
24 KiB
TypeScript
789 lines
24 KiB
TypeScript
import {
|
|
ErrorCodes,
|
|
KimiError,
|
|
type AgentContextData,
|
|
type KimiErrorCode,
|
|
type SwarmModeTrigger,
|
|
} from '@moonshot-ai/agent-core';
|
|
|
|
import { type ApprovalHandler, type Event, type QuestionHandler } from '#/events';
|
|
import type { SDKRpcClientBase } from '#/rpc';
|
|
import type {
|
|
AddAdditionalDirOptions,
|
|
AddAdditionalDirResult,
|
|
AgentCommandInfo,
|
|
BackgroundTaskInfo,
|
|
CapabilityStatus,
|
|
CompactOptions,
|
|
CreateGoalInput,
|
|
GetCronTasksResult,
|
|
GoalSnapshot,
|
|
GoalToolResult,
|
|
JsonObject,
|
|
McpServerInfo,
|
|
McpStartupMetrics,
|
|
PermissionMode,
|
|
PluginInfo,
|
|
PluginSummary,
|
|
PromptInput,
|
|
ReloadSessionOptions,
|
|
ReloadSummary,
|
|
ResumedSessionState,
|
|
ResumedSessionSummary,
|
|
SessionPlan,
|
|
SessionStatus,
|
|
SessionSummary,
|
|
SessionUsage,
|
|
SkillSummary,
|
|
PluginCommandDef,
|
|
ThinkingEffort,
|
|
Unsubscribe,
|
|
} from '#/types';
|
|
|
|
const MAIN_AGENT_ID = 'main';
|
|
|
|
export interface SessionOptions {
|
|
readonly id: string;
|
|
readonly workDir: string;
|
|
readonly summary?: SessionSummary | undefined;
|
|
readonly resumeState?: ResumedSessionState | undefined;
|
|
readonly rpc: SDKRpcClientBase;
|
|
readonly onClose?: (() => void | Promise<void>) | undefined;
|
|
}
|
|
|
|
/**
|
|
* The capability surface (built-in product capabilities: kimi-cu,
|
|
* kimi-webbridge) exists only on the v2 engine — v1 has no capability
|
|
* domain. Feature-detect structurally so a Session backed by v1 fails with
|
|
* a clear message instead of a confusing missing-method error.
|
|
*/
|
|
interface CapabilityRpcSurface {
|
|
listCapabilities(): Promise<readonly CapabilityStatus[]>;
|
|
getCapability(id: string): Promise<CapabilityStatus>;
|
|
installCapability(id: string): Promise<CapabilityStatus>;
|
|
}
|
|
|
|
export function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface {
|
|
const candidate = rpc as Partial<CapabilityRpcSurface>;
|
|
if (
|
|
typeof candidate.listCapabilities !== 'function' ||
|
|
typeof candidate.getCapability !== 'function' ||
|
|
typeof candidate.installCapability !== 'function'
|
|
) {
|
|
throw new TypeError('The capability surface is unavailable on this engine (requires v2).');
|
|
}
|
|
return candidate as CapabilityRpcSurface;
|
|
}
|
|
|
|
export class Session {
|
|
readonly id: string;
|
|
readonly workDir: string;
|
|
summary?: SessionSummary | undefined;
|
|
private resumeState: ResumedSessionState | undefined;
|
|
|
|
private readonly rpc: SDKRpcClientBase;
|
|
private readonly onClose?: (() => void | Promise<void>) | undefined;
|
|
private closed = false;
|
|
|
|
constructor(options: SessionOptions) {
|
|
this.id = options.id;
|
|
this.workDir = options.workDir;
|
|
this.summary = options.summary;
|
|
this.resumeState = options.resumeState ?? resumeStateFromSummary(options.summary);
|
|
this.rpc = options.rpc;
|
|
this.onClose = options.onClose;
|
|
}
|
|
|
|
getResumeState(): ResumedSessionState | undefined {
|
|
this.ensureOpen();
|
|
return this.resumeState;
|
|
}
|
|
|
|
async reloadSession(options?: ReloadSessionOptions): Promise<ResumedSessionSummary> {
|
|
this.ensureOpen();
|
|
const summary = await this.rpc.reloadSession({
|
|
sessionId: this.id,
|
|
forcePluginSessionStartReminder: options?.forcePluginSessionStartReminder,
|
|
});
|
|
this.summary = summary;
|
|
this.resumeState = resumeStateFromSummary(summary);
|
|
return summary;
|
|
}
|
|
|
|
onEvent(listener: (event: Event) => void): Unsubscribe {
|
|
this.ensureOpen();
|
|
return this.rpc.onEvent((event) => {
|
|
if (event.sessionId === this.id) {
|
|
listener(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
setApprovalHandler(handler: ApprovalHandler | undefined): void {
|
|
this.ensureOpen();
|
|
this.rpc.setApprovalHandler(this.id, handler);
|
|
}
|
|
|
|
setQuestionHandler(handler: QuestionHandler | undefined): void {
|
|
this.ensureOpen();
|
|
this.rpc.setQuestionHandler(this.id, handler);
|
|
}
|
|
|
|
async prompt(input: string | PromptInput): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.prompt({
|
|
sessionId: this.id,
|
|
input: normalizePromptInput(input),
|
|
});
|
|
}
|
|
|
|
/** Execute a user-initiated `!` shell command (silent — does not prompt the
|
|
* model). Resolves with the command's stdout/stderr for immediate display.
|
|
* Pass `commandId` to receive live `shell.output` events for this command. */
|
|
async runShellCommand(
|
|
command: string,
|
|
options?: { commandId?: string },
|
|
): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> {
|
|
this.ensureOpen();
|
|
return this.rpc.runShellCommand({
|
|
sessionId: this.id,
|
|
command,
|
|
commandId: options?.commandId,
|
|
});
|
|
}
|
|
|
|
/** Cancel a running `!` shell command by its commandId (e.g. on Esc / Ctrl+C). */
|
|
async cancelShellCommand(commandId: string): Promise<void> {
|
|
this.ensureOpen();
|
|
return this.rpc.cancelShellCommand({ sessionId: this.id, commandId });
|
|
}
|
|
|
|
async steer(input: string | PromptInput): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.steer({
|
|
sessionId: this.id,
|
|
input: normalizePromptInput(input),
|
|
});
|
|
}
|
|
|
|
async swarm(input: string | PromptInput): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.swarm({
|
|
sessionId: this.id,
|
|
input: normalizePromptInput(input),
|
|
});
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.generateAgentsMd({ sessionId: this.id });
|
|
}
|
|
|
|
async getSessionWarnings() {
|
|
this.ensureOpen();
|
|
return this.rpc.getSessionWarnings({ sessionId: this.id });
|
|
}
|
|
|
|
async addAdditionalDir(
|
|
path: string,
|
|
options?: AddAdditionalDirOptions,
|
|
): Promise<AddAdditionalDirResult> {
|
|
this.ensureOpen();
|
|
const normalized = normalizeRequiredString(
|
|
path,
|
|
'Additional directory cannot be empty',
|
|
ErrorCodes.REQUEST_INVALID,
|
|
);
|
|
const result = await this.rpc.addAdditionalDir({
|
|
id: this.id,
|
|
path: normalized,
|
|
persist: options?.persist ?? true,
|
|
});
|
|
this.summary = { ...this.requireSummary(), additionalDirs: result.additionalDirs };
|
|
return result;
|
|
}
|
|
|
|
async startBtw(): Promise<string> {
|
|
this.ensureOpen();
|
|
return this.rpc.startBtw({ sessionId: this.id });
|
|
}
|
|
|
|
async cancel(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.cancel({ sessionId: this.id });
|
|
}
|
|
|
|
async setModel(model: string): Promise<void> {
|
|
this.ensureOpen();
|
|
const normalized = normalizeRequiredString(
|
|
model,
|
|
'Session model cannot be empty',
|
|
ErrorCodes.SESSION_MODEL_EMPTY,
|
|
);
|
|
await this.rpc.setModel({ sessionId: this.id, model: normalized });
|
|
}
|
|
|
|
async setThinking(effort: ThinkingEffort): Promise<void> {
|
|
this.ensureOpen();
|
|
const normalized = normalizeRequiredString(
|
|
effort,
|
|
'Session thinking effort cannot be empty',
|
|
ErrorCodes.SESSION_THINKING_EMPTY,
|
|
);
|
|
await this.rpc.setThinking({ sessionId: this.id, effort: normalized });
|
|
}
|
|
|
|
/**
|
|
* Live-apply the persisted `[secondary_model]` recipe to this session
|
|
* (subagent model binding). Persist the recipe via `KimiHarness.setConfig`
|
|
* first; this reloads the complete recipe and its synthesized derived entry
|
|
* before updating the session snapshot — mirroring the `/secondary_model`
|
|
* flow.
|
|
*/
|
|
async applyPersistedSecondaryModel(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.applyPersistedSecondaryModel({ sessionId: this.id });
|
|
}
|
|
|
|
async setPermission(mode: PermissionMode): Promise<void> {
|
|
this.ensureOpen();
|
|
if (!isPermissionMode(mode)) {
|
|
throw new KimiError(
|
|
ErrorCodes.SESSION_PERMISSION_MODE_INVALID,
|
|
'Session permission mode must be yolo, manual, or auto',
|
|
);
|
|
}
|
|
await this.rpc.setPermission({ sessionId: this.id, mode });
|
|
}
|
|
|
|
/** Shallow-merge host-owned fields into this session's persisted custom metadata. */
|
|
async updateMetadata(patch: JsonObject): Promise<void> {
|
|
this.ensureOpen();
|
|
if (Object.hasOwn(patch, 'goal')) {
|
|
throw new KimiError(
|
|
ErrorCodes.GOAL_METADATA_RESERVED,
|
|
'Session metadata key "goal" is reserved for the goal lifecycle',
|
|
);
|
|
}
|
|
const summary = this.requireSummary();
|
|
await this.rpc.updateSessionMetadata({ sessionId: this.id, metadata: patch });
|
|
const metadata = { ...summary.metadata, ...patch };
|
|
this.summary = { ...summary, metadata };
|
|
if (this.resumeState !== undefined) {
|
|
this.resumeState = {
|
|
...this.resumeState,
|
|
sessionMetadata: {
|
|
...this.resumeState.sessionMetadata,
|
|
custom: { ...this.resumeState.sessionMetadata.custom, ...patch },
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
async setPlanMode(enabled: boolean): Promise<void> {
|
|
this.ensureOpen();
|
|
if (typeof enabled !== 'boolean') {
|
|
throw new KimiError(
|
|
ErrorCodes.SESSION_PLAN_MODE_INVALID,
|
|
'Session plan mode must be a boolean',
|
|
);
|
|
}
|
|
await this.rpc.setPlanMode({ sessionId: this.id, enabled });
|
|
}
|
|
|
|
async setSwarmMode(enabled: boolean, trigger: SwarmModeTrigger): Promise<void> {
|
|
this.ensureOpen();
|
|
if (typeof enabled !== 'boolean') {
|
|
throw new KimiError(
|
|
ErrorCodes.REQUEST_INVALID,
|
|
'Session swarm mode must be a boolean',
|
|
);
|
|
}
|
|
if (enabled) {
|
|
await this.rpc.setSwarmMode({ sessionId: this.id, enabled: true, trigger });
|
|
} else {
|
|
await this.rpc.setSwarmMode({ sessionId: this.id, enabled: false });
|
|
}
|
|
}
|
|
|
|
async getPlan(): Promise<SessionPlan> {
|
|
this.ensureOpen();
|
|
return this.rpc.getPlan({ sessionId: this.id });
|
|
}
|
|
|
|
async clearPlan(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.clearPlan({ sessionId: this.id });
|
|
}
|
|
|
|
async compact(options: CompactOptions = {}): Promise<void> {
|
|
this.ensureOpen();
|
|
const instruction = normalizeOptionalString(options.instruction);
|
|
await this.rpc.compact({
|
|
sessionId: this.id,
|
|
...(instruction !== undefined ? { instruction } : {}),
|
|
});
|
|
}
|
|
|
|
async cancelCompaction(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.cancelCompaction({ sessionId: this.id });
|
|
}
|
|
|
|
async undoHistory(count: number = 1): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.undoHistory({ sessionId: this.id, count });
|
|
}
|
|
|
|
/** Clear this session's model context without creating a new session. */
|
|
async clearContext(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.clearContext({ sessionId: this.id });
|
|
}
|
|
|
|
/** Append imported text to this session's context without prompting the model. */
|
|
async importContext(content: string, source: string): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.importContext({ sessionId: this.id, content, source });
|
|
}
|
|
|
|
async getContext(): Promise<AgentContextData> {
|
|
this.ensureOpen();
|
|
return this.rpc.getContext({ sessionId: this.id });
|
|
}
|
|
|
|
async getUsage(): Promise<SessionUsage> {
|
|
this.ensureOpen();
|
|
return this.rpc.getUsage({ sessionId: this.id });
|
|
}
|
|
|
|
async getStatus(): Promise<SessionStatus> {
|
|
this.ensureOpen();
|
|
return this.rpc.getStatus({ sessionId: this.id });
|
|
}
|
|
|
|
async listSkills(): Promise<readonly SkillSummary[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listSkills({ sessionId: this.id });
|
|
}
|
|
|
|
async listPluginCommands(): Promise<readonly PluginCommandDef[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listPluginCommands({ sessionId: this.id });
|
|
}
|
|
|
|
/**
|
|
* Contributed commands registered with this session's interactive agent
|
|
* (agent-core-v2 only — a v1-backed session reports the empty set).
|
|
*/
|
|
async listCommands(): Promise<readonly AgentCommandInfo[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listCommands({ sessionId: this.id });
|
|
}
|
|
|
|
/**
|
|
* List background tasks for this session's interactive agent.
|
|
*
|
|
* Defaults to all tasks (including terminal/lost). Pass
|
|
* `{ activeOnly: true }` to filter to non-terminal entries.
|
|
*/
|
|
async listBackgroundTasks(
|
|
options: { activeOnly?: boolean; limit?: number } = {},
|
|
): Promise<readonly BackgroundTaskInfo[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listBackgroundTasks({
|
|
sessionId: this.id,
|
|
activeOnly: options.activeOnly,
|
|
limit: options.limit,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read a background task's captured output. Returns the in-memory
|
|
* ring buffer if available, otherwise falls back to the persisted
|
|
* `<sessionDir>/tasks/<taskId>/output.log`. `tail` caps the returned
|
|
* string to that many trailing characters.
|
|
*/
|
|
async getBackgroundTaskOutput(
|
|
taskId: string,
|
|
options: { tail?: number } = {},
|
|
): Promise<string> {
|
|
this.ensureOpen();
|
|
const trimmedTaskId = normalizeRequiredString(
|
|
taskId,
|
|
'Task id cannot be empty',
|
|
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
|
|
);
|
|
return this.rpc.getBackgroundTaskOutput({
|
|
sessionId: this.id,
|
|
taskId: trimmedTaskId,
|
|
tail: options.tail,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Request a running background task to stop. Sends SIGTERM with a
|
|
* grace period (handled by the core BPM); subscribers receive a
|
|
* `background.task.terminated` event when the kill settles. Calls
|
|
* for unknown or already-terminal task ids are no-ops at the core
|
|
* level — this method does not throw in those cases.
|
|
*/
|
|
async stopBackgroundTask(
|
|
taskId: string,
|
|
options: { reason?: string } = {},
|
|
): Promise<void> {
|
|
this.ensureOpen();
|
|
const trimmedTaskId = normalizeRequiredString(
|
|
taskId,
|
|
'Task id cannot be empty',
|
|
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
|
|
);
|
|
await this.rpc.stopBackgroundTask({
|
|
sessionId: this.id,
|
|
taskId: trimmedTaskId,
|
|
reason: options.reason,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detach a running foreground task so the current tool call can return while
|
|
* the task continues under background-task management.
|
|
*/
|
|
async detachBackgroundTask(taskId: string): Promise<BackgroundTaskInfo | undefined> {
|
|
this.ensureOpen();
|
|
const trimmedTaskId = normalizeRequiredString(
|
|
taskId,
|
|
'Task id cannot be empty',
|
|
ErrorCodes.BACKGROUND_TASK_ID_EMPTY,
|
|
);
|
|
return this.rpc.detachBackgroundTask({
|
|
sessionId: this.id,
|
|
taskId: trimmedTaskId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Block until every still-running background task (across all agents in this
|
|
* session) reaches a terminal state. Used by `kimi -p` after the main agent's
|
|
* turn finishes when the resolved print background mode is `'drain'`
|
|
* (`print_background_mode = "drain"`, or the legacy `keep_alive_on_exit = true`
|
|
* fallback), so background subagents get a chance to complete before the process
|
|
* exits. No-op in other modes. Bounded by `background.print_wait_ceiling_s`.
|
|
*/
|
|
async waitForBackgroundTasksOnPrint(): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.waitForBackgroundTasksOnPrint({ sessionId: this.id });
|
|
}
|
|
|
|
/**
|
|
* Used by `kimi -p` after the main agent's turn ends with `reason ===
|
|
* 'completed'`. Returns `'finish'` when the run may exit, or `'continue'` when
|
|
* the caller must keep the session alive so a background-task completion can
|
|
* steer the main agent into a new turn. Policy is selected by
|
|
* `background.print_background_mode` (`'exit' | 'drain' | 'steer'`); when unset
|
|
* it falls back to the legacy `keep_alive_on_exit` mapping (`true ⇒ 'drain'`).
|
|
*/
|
|
async handlePrintMainTurnCompleted(): Promise<'finish' | 'continue'> {
|
|
this.ensureOpen();
|
|
return this.rpc.handlePrintMainTurnCompleted({ sessionId: this.id });
|
|
}
|
|
|
|
// --- Goal lifecycle ---------------------------------------------------
|
|
// Deterministic user/host control surface. There is intentionally no
|
|
// `updateGoal`: the goal's terminal status is decided by the model via the
|
|
// in-conversation UpdateGoal tool (or the goal driver on budget/error), not
|
|
// by the host.
|
|
|
|
async createGoal(input: CreateGoalInput): Promise<GoalSnapshot> {
|
|
this.ensureOpen();
|
|
return this.rpc.createGoal({ sessionId: this.id, ...input });
|
|
}
|
|
|
|
async getGoal(): Promise<GoalToolResult> {
|
|
this.ensureOpen();
|
|
return this.rpc.getGoal({ sessionId: this.id });
|
|
}
|
|
|
|
async pauseGoal(): Promise<GoalSnapshot> {
|
|
this.ensureOpen();
|
|
return this.rpc.pauseGoal({ sessionId: this.id });
|
|
}
|
|
|
|
async resumeGoal(): Promise<GoalSnapshot> {
|
|
this.ensureOpen();
|
|
return this.rpc.resumeGoal({ sessionId: this.id });
|
|
}
|
|
|
|
async cancelGoal(): Promise<GoalSnapshot> {
|
|
this.ensureOpen();
|
|
return this.rpc.cancelGoal({ sessionId: this.id });
|
|
}
|
|
|
|
/**
|
|
* Enumerate the cron tasks scheduled in this session. Hosts running a
|
|
* bounded session lifetime (e.g. `kimi -p`) poll this to decide whether
|
|
* pending scheduled work still needs the process alive.
|
|
*/
|
|
async getCronTasks(): Promise<GetCronTasksResult> {
|
|
this.ensureOpen();
|
|
return this.rpc.getCronTasks({ sessionId: this.id });
|
|
}
|
|
|
|
async listMcpServers(): Promise<readonly McpServerInfo[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listMcpServers({ sessionId: this.id });
|
|
}
|
|
|
|
async getMcpStartupMetrics(): Promise<McpStartupMetrics> {
|
|
this.ensureOpen();
|
|
return this.rpc.getMcpStartupMetrics({ sessionId: this.id });
|
|
}
|
|
|
|
async reconnectMcpServer(name: string): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.reconnectMcpServer({ sessionId: this.id, name });
|
|
}
|
|
|
|
async listPlugins(): Promise<readonly PluginSummary[]> {
|
|
this.ensureOpen();
|
|
return this.rpc.listPlugins();
|
|
}
|
|
|
|
async installPlugin(source: string): Promise<PluginSummary> {
|
|
this.ensureOpen();
|
|
return this.rpc.installPlugin(source);
|
|
}
|
|
|
|
async setPluginEnabled(id: string, enabled: boolean): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.setPluginEnabled(id, enabled);
|
|
}
|
|
|
|
/** Built-in capabilities with layered readiness (v2 engine only). */
|
|
async listCapabilities(): Promise<readonly CapabilityStatus[]> {
|
|
this.ensureOpen();
|
|
return capabilityRpc(this.rpc).listCapabilities();
|
|
}
|
|
|
|
/** One capability's layered readiness + live install progress. */
|
|
async getCapability(id: string): Promise<CapabilityStatus> {
|
|
this.ensureOpen();
|
|
return capabilityRpc(this.rpc).getCapability(id);
|
|
}
|
|
|
|
/**
|
|
* Start an idempotent capability install (binary runtime + wiring) in the
|
|
* background; poll `getCapability` for progress.
|
|
*/
|
|
async installCapability(id: string): Promise<CapabilityStatus> {
|
|
this.ensureOpen();
|
|
return capabilityRpc(this.rpc).installCapability(id);
|
|
}
|
|
|
|
async setPluginMcpServerEnabled(
|
|
id: string,
|
|
server: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.setPluginMcpServerEnabled(id, server, enabled);
|
|
}
|
|
|
|
async removePlugin(id: string): Promise<void> {
|
|
this.ensureOpen();
|
|
await this.rpc.removePlugin(id);
|
|
}
|
|
|
|
async reloadPlugins(): Promise<ReloadSummary> {
|
|
this.ensureOpen();
|
|
return this.rpc.reloadPlugins();
|
|
}
|
|
|
|
async getPluginInfo(id: string): Promise<PluginInfo> {
|
|
this.ensureOpen();
|
|
return this.rpc.getPluginInfo(id);
|
|
}
|
|
|
|
async activateSkill(name: string, args?: string | undefined): Promise<void> {
|
|
this.ensureOpen();
|
|
const skillName = normalizeRequiredString(
|
|
name,
|
|
'Skill name cannot be empty',
|
|
ErrorCodes.SKILL_NAME_EMPTY,
|
|
);
|
|
const skillArgs = normalizeOptionalString(args);
|
|
await this.rpc.activateSkill({
|
|
sessionId: this.id,
|
|
name: skillName,
|
|
...(skillArgs !== undefined ? { args: skillArgs } : {}),
|
|
});
|
|
}
|
|
|
|
async activatePluginCommand(
|
|
pluginId: string,
|
|
commandName: string,
|
|
args?: string | undefined,
|
|
): Promise<void> {
|
|
this.ensureOpen();
|
|
const normalizedPluginId = pluginId.trim();
|
|
const normalizedCommandName = commandName.trim();
|
|
if (normalizedPluginId.length === 0 || normalizedCommandName.length === 0) {
|
|
throw new KimiError(
|
|
ErrorCodes.REQUEST_INVALID,
|
|
'Plugin id and command name cannot be empty',
|
|
);
|
|
}
|
|
const commandArgs = normalizeOptionalString(args);
|
|
await this.rpc.activatePluginCommand({
|
|
sessionId: this.id,
|
|
pluginId: normalizedPluginId,
|
|
commandName: normalizedCommandName,
|
|
...(commandArgs !== undefined ? { args: commandArgs } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run a contributed command engine-side (agent-core-v2 only — a v1-backed
|
|
* client rejects with `not_implemented`). Unknown names reject with the
|
|
* engine's `request.invalid` error.
|
|
*/
|
|
async runCommand(name: string, args?: string): Promise<void> {
|
|
this.ensureOpen();
|
|
const commandName = name.trim();
|
|
if (commandName.length === 0) {
|
|
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Command name cannot be empty');
|
|
}
|
|
await this.rpc.runCommand({
|
|
sessionId: this.id,
|
|
name: commandName,
|
|
args: normalizeOptionalString(args),
|
|
});
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
try {
|
|
await this.rpc.closeSession({ sessionId: this.id });
|
|
} finally {
|
|
this.rpc.clearSessionHandlers(this.id);
|
|
await this.onClose?.();
|
|
}
|
|
}
|
|
|
|
/** @internal */
|
|
emitMetaUpdated(patch: { readonly title?: string | undefined }): void {
|
|
this.emit({
|
|
type: 'session.meta.updated',
|
|
sessionId: this.id,
|
|
agentId: MAIN_AGENT_ID,
|
|
title: patch.title,
|
|
patch,
|
|
});
|
|
}
|
|
|
|
private emit(event: Event): void {
|
|
this.rpc.receiveEvent(event);
|
|
}
|
|
|
|
private ensureOpen(): void {
|
|
if (this.closed) {
|
|
throw new KimiError(ErrorCodes.SESSION_CLOSED, 'Session is closed');
|
|
}
|
|
}
|
|
|
|
private requireSummary(): SessionSummary {
|
|
if (this.summary === undefined) {
|
|
throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, 'Session summary is unavailable');
|
|
}
|
|
return this.summary;
|
|
}
|
|
}
|
|
|
|
function normalizePromptInput(input: string | PromptInput): PromptInput {
|
|
if (typeof input === 'string') {
|
|
if (input.trim().length === 0) {
|
|
throw new KimiError(ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, 'Prompt input cannot be empty');
|
|
}
|
|
return [{ type: 'text', text: input }];
|
|
}
|
|
|
|
if (input.length === 0) {
|
|
throw new KimiError(ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, 'Prompt input cannot be empty');
|
|
}
|
|
|
|
for (const part of input) {
|
|
switch (part.type) {
|
|
case 'text':
|
|
if (part.text.trim().length === 0) {
|
|
throw new KimiError(
|
|
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
|
|
'Prompt input cannot contain empty text parts',
|
|
);
|
|
}
|
|
break;
|
|
case 'image_url':
|
|
if (part.imageUrl.url.trim().length === 0) {
|
|
throw new KimiError(
|
|
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
|
|
'Prompt input cannot contain empty image URLs',
|
|
);
|
|
}
|
|
break;
|
|
case 'video_url':
|
|
if (part.videoUrl.url.trim().length === 0) {
|
|
throw new KimiError(
|
|
ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY,
|
|
'Prompt input cannot contain empty video URLs',
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return input;
|
|
}
|
|
|
|
function normalizeRequiredString(
|
|
value: string,
|
|
message: string,
|
|
code: KimiErrorCode,
|
|
): string {
|
|
const normalized = value.trim();
|
|
if (normalized.length === 0) {
|
|
throw new KimiError(code, message);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
if (value === undefined) return undefined;
|
|
const normalized = value.trim();
|
|
return normalized.length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function isPermissionMode(value: unknown): value is PermissionMode {
|
|
return value === 'yolo' || value === 'manual' || value === 'auto';
|
|
}
|
|
|
|
function resumeStateFromSummary(
|
|
summary: SessionSummary | undefined,
|
|
): ResumedSessionState | undefined {
|
|
if (!hasResumeState(summary)) return undefined;
|
|
return {
|
|
sessionMetadata: summary.sessionMetadata,
|
|
agents: summary.agents,
|
|
warning: summary.warning,
|
|
};
|
|
}
|
|
|
|
function hasResumeState(
|
|
summary: SessionSummary | undefined,
|
|
): summary is SessionSummary & ResumedSessionState {
|
|
return (
|
|
summary !== undefined &&
|
|
typeof (summary as { readonly sessionMetadata?: unknown }).sessionMetadata === 'object' &&
|
|
(summary as { readonly sessionMetadata?: unknown }).sessionMetadata !== null &&
|
|
typeof (summary as { readonly agents?: unknown }).agents === 'object' &&
|
|
(summary as { readonly agents?: unknown }).agents !== null
|
|
);
|
|
}
|