From 339022b37566ee198052147400aa83dbce2a5298 Mon Sep 17 00:00:00 2001
From: 7Sageer <7sageer@djwcb.cn>
Date: Tue, 7 Jul 2026 12:35:49 +0800
Subject: [PATCH] feat(agent-core-v2): add workspace add-dir command
---
.../agent-core-v2/docs/di-scope-domains.puml | 5 +
.../agent-core-v2/docs/di-scope-domains.svg | 2 +-
.../scripts/check-domain-layers.mjs | 7 +
packages/agent-core-v2/src/index.ts | 1 +
.../src/session/workspaceCommand/index.ts | 11 +
.../workspaceCommand/workspaceCommand.ts | 33 ++
.../workspaceCommandService.ts | 138 +++++++
.../workspaceCommand/workspaceLocalConfig.ts | 318 +++++++++++++++
.../workspaceContext/workspaceContext.ts | 1 +
.../workspaceContextService.ts | 4 +
.../test/fileTools/stub-workspace-context.ts | 1 +
.../permission-policy-service.test.ts | 3 +
.../test/sessionFs/fsService.test.ts | 1 +
.../test/skill/skillCatalog.test.ts | 1 +
.../test/terminal/terminalService.test.ts | 1 +
.../workspace-command.test.ts | 373 ++++++++++++++++++
packages/server-v2/src/transport/actionMap.ts | 6 +-
17 files changed, 904 insertions(+), 2 deletions(-)
create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/index.ts
create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts
create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts
create mode 100644 packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts
create mode 100644 packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts
diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 324ff55e0..6839add75 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -54,6 +54,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "agentLifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "interaction\nSession\n IInteractionService" as interaction #D5F5E3
rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3
+ rectangle "workspaceCommand\nSession\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
rectangle "sessionLog\nSession binding\n ILogService" as sessionLog #D5F5E3
rectangle "sessionSkillCatalog\nSession\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3
rectangle "sessionFs\nSession\n ISessionFsService" as sessionFs #D5F5E3
@@ -141,6 +142,10 @@ modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
workspaceContext --> session_context #34495E
+workspaceCommand --> bootstrap #34495E
+workspaceCommand --> workspaceContext #34495E
+workspaceCommand --> agent_lifecycle #34495E
+workspaceCommand --> hostFs #34495E
sessionLog --> session_context #34495E
sessionSkillCatalog --> skillCatalog #34495E
sessionSkillCatalog --> plugin #34495E
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index c53d526db..a2b79101b 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 505676b08..b244061fb 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -177,6 +177,13 @@ const DOMAIN_LAYER = new Map([
['sessionActivity', 6],
['session', 6],
['terminal', 6],
+ // `workspaceCommand` orchestrates session-level workspace mutations
+ // (`addAdditionalDir`): it reaches through `agentLifecycle` (L6) to the
+ // `main` agent's `contextMemory` (L4) to mirror the action's stdout, and
+ // reads/writes the workspace-local config through `os/interface` (L1). Its
+ // highest real dependency is `agentLifecycle`, so it sits in L6 beside the
+ // other coordination domains.
+ ['workspaceCommand', 6],
// L7 — boundary
['approval', 7],
['question', 7],
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index 3a72dd944..e74a33947 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -71,6 +71,7 @@ export * from '#/agent/questionTools';
export * from '#/app/gateway';
export * from '#/session/workspaceContext';
+export * from '#/session/workspaceCommand';
export * from '#/app/workspaceRegistry';
export * from '#/session/process';
export * from '#/session/sessionFs';
diff --git a/packages/agent-core-v2/src/session/workspaceCommand/index.ts b/packages/agent-core-v2/src/session/workspaceCommand/index.ts
new file mode 100644
index 000000000..8e15a44a0
--- /dev/null
+++ b/packages/agent-core-v2/src/session/workspaceCommand/index.ts
@@ -0,0 +1,11 @@
+/**
+ * `workspaceCommand` domain barrel — re-exports the workspace-command contract
+ * (`workspaceCommand`), its scoped service (`workspaceCommandService`), and the
+ * workspace-local-config helpers (`workspaceLocalConfig`). Importing this
+ * barrel registers the `ISessionWorkspaceCommandService` binding into the scope
+ * registry.
+ */
+
+export * from './workspaceCommand';
+export * from './workspaceCommandService';
+export * from './workspaceLocalConfig';
diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts
new file mode 100644
index 000000000..70f85ec5c
--- /dev/null
+++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommand.ts
@@ -0,0 +1,33 @@
+/**
+ * `workspaceCommand` domain (L6) — workspace mutation command contract.
+ *
+ * Defines the `ISessionWorkspaceCommandService` that orchestrates session-level
+ * workspace mutations (`addAdditionalDir`): persisting workspace-local config
+ * when asked, updating `ISessionWorkspaceContext`, and mirroring the
+ * action's stdout into the main agent's context as a `local-command-stdout`
+ * injection so the agent observes the change. Session-scoped.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface AddAdditionalDirInput {
+ readonly path: string;
+ /** When `true` (default), persist the directory into `/.kimi-code/local.toml`. */
+ readonly persist?: boolean;
+}
+
+export interface WorkspaceAdditionalDirsResult {
+ readonly projectRoot: string;
+ readonly configPath: string;
+ readonly additionalDirs: readonly string[];
+ readonly persisted: boolean;
+}
+
+export interface ISessionWorkspaceCommandService {
+ readonly _serviceBrand: undefined;
+
+ addAdditionalDir(input: AddAdditionalDirInput): Promise;
+}
+
+export const ISessionWorkspaceCommandService: ServiceIdentifier =
+ createDecorator('sessionWorkspaceCommandService');
diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts
new file mode 100644
index 000000000..139477513
--- /dev/null
+++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts
@@ -0,0 +1,138 @@
+/**
+ * `workspaceCommand` domain (L6) — `ISessionWorkspaceCommandService` implementation.
+ *
+ * Coordinates session-level workspace mutations. `addAdditionalDir` persists
+ * the directory into the workspace-local config file when `persist` is true
+ * (`/.kimi-code/local.toml`, through `IHostFileSystem`), updates
+ * `ISessionWorkspaceContext`, and mirrors the action's stdout into the main
+ * agent's context as a `local-command-stdout` injection (via
+ * `IAgentContextMemoryService` on the `main` handle from `agentLifecycle`).
+ * If the main agent does not exist yet, the injection is queued and flushed
+ * from the `onDidCreateMain` subscription. Bound at Session scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { Disposable } from '#/_base/di/lifecycle';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { IBootstrapService } from '#/app/bootstrap';
+import { IHostFileSystem } from '#/os/interface/hostFileSystem';
+import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
+import { ISessionWorkspaceContext } from '#/session/workspaceContext';
+
+import {
+ type AddAdditionalDirInput,
+ ISessionWorkspaceCommandService,
+ type WorkspaceAdditionalDirsResult,
+} from './workspaceCommand';
+import {
+ appendWorkspaceAdditionalDir,
+ normalizeAdditionalDirs,
+ readWorkspaceAdditionalDirs,
+ resolveWorkspaceAdditionalDirs,
+ type WorkspaceLocalDeps,
+} from './workspaceLocalConfig';
+
+export class SessionWorkspaceCommandService
+ extends Disposable
+ implements ISessionWorkspaceCommandService
+{
+ declare readonly _serviceBrand: undefined;
+ private readonly pendingMainInjections: ContextMessage[] = [];
+ private mutationQueue: Promise = Promise.resolve();
+
+ constructor(
+ @IBootstrapService private readonly bootstrap: IBootstrapService,
+ @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
+ @IAgentLifecycleService private readonly agents: IAgentLifecycleService,
+ @IHostFileSystem private readonly hostFs: IHostFileSystem,
+ ) {
+ super();
+ this._register(
+ this.agents.onDidCreateMain((handle) => {
+ if (this.pendingMainInjections.length === 0) return;
+ const pending = this.pendingMainInjections.splice(0);
+ handle.accessor.get(IAgentContextMemoryService).append(...pending);
+ }),
+ );
+ }
+
+ async addAdditionalDir(input: AddAdditionalDirInput): Promise {
+ return this.enqueueMutation(() => this.applyAddAdditionalDir(input));
+ }
+
+ private async applyAddAdditionalDir(
+ input: AddAdditionalDirInput,
+ ): Promise {
+ const persist = input.persist ?? true;
+ const deps: WorkspaceLocalDeps = { fs: this.hostFs, homeDir: this.bootstrap.homeDir };
+
+ if (persist) {
+ const persisted = await appendWorkspaceAdditionalDir(
+ deps,
+ this.workspace.workDir,
+ input.path,
+ );
+ const additionalDirs = normalizeAdditionalDirs([
+ ...this.workspace.additionalDirs,
+ ...persisted.additionalDirs,
+ ]);
+ this.workspace.setAdditionalDirs(additionalDirs);
+ this.injectAdditionalDirAdded(input.path, true, persisted.configPath);
+ return {
+ projectRoot: persisted.projectRoot,
+ configPath: persisted.configPath,
+ additionalDirs,
+ persisted: true,
+ };
+ }
+
+ const workspace = await readWorkspaceAdditionalDirs(deps, this.workspace.workDir);
+ const resolved = await resolveWorkspaceAdditionalDirs(deps, this.workspace.workDir, [
+ input.path,
+ ]);
+ const additionalDirs = normalizeAdditionalDirs([...this.workspace.additionalDirs, ...resolved]);
+ this.workspace.setAdditionalDirs(additionalDirs);
+ this.injectAdditionalDirAdded(input.path, false, workspace.configPath);
+ return {
+ projectRoot: workspace.projectRoot,
+ configPath: workspace.configPath,
+ additionalDirs,
+ persisted: false,
+ };
+ }
+
+ private enqueueMutation(work: () => Promise): Promise {
+ const run = this.mutationQueue.then(work, work);
+ this.mutationQueue = run.then(() => undefined, () => undefined);
+ return run;
+ }
+
+ private injectAdditionalDirAdded(path: string, persisted: boolean, configPath: string): void {
+ const stdout = persisted
+ ? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
+ : `Added workspace directory:\n ${path}\n For this session only`;
+ const text = `\n${stdout.trim()}\n`;
+ const message: ContextMessage = {
+ role: 'user',
+ content: [{ type: 'text', text }],
+ toolCalls: [],
+ origin: { kind: 'injection', variant: 'local-command-stdout' },
+ };
+
+ const main = this.agents.getHandle(MAIN_AGENT_ID);
+ if (main !== undefined) {
+ main.accessor.get(IAgentContextMemoryService).append(message);
+ return;
+ }
+ this.pendingMainInjections.push(message);
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ ISessionWorkspaceCommandService,
+ SessionWorkspaceCommandService,
+ InstantiationType.Delayed,
+ 'workspaceCommand',
+);
diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts
new file mode 100644
index 000000000..8aaa7a7c2
--- /dev/null
+++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceLocalConfig.ts
@@ -0,0 +1,318 @@
+/**
+ * `workspaceCommand` domain (L6) — workspace local-config file helpers.
+ *
+ * Reads and writes the `/.kimi-code/local.toml` file that records
+ * additional workspace directories persisted across sessions. Pure IO
+ * functions over `IHostFileSystem` plus the host home directory; no scoped
+ * state. Ported from v1's `config/workspace-local.ts` with the Kaos primitive
+ * swapped for v2's host filesystem.
+ */
+
+import { dirname, isAbsolute, join, normalize, resolve } from 'pathe';
+import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
+import { z } from 'zod';
+
+import { ErrorCodes, KimiError } from '#/errors';
+import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
+
+const WorkspaceLocalTomlSchema = z.object({
+ workspace: z
+ .object({
+ additional_dir: z.array(z.string()),
+ })
+ .optional(),
+});
+
+type WorkspaceLocalToml = z.infer;
+
+export interface WorkspaceLocalDeps {
+ readonly fs: IHostFileSystem;
+ readonly homeDir: string;
+}
+
+export interface WorkspaceAdditionalDirsLoadResult {
+ readonly projectRoot: string;
+ readonly configPath: string;
+ readonly additionalDirs: readonly string[];
+}
+
+interface WorkspaceLocalTomlFile {
+ readonly raw: Record;
+ readonly parsed: WorkspaceLocalToml;
+}
+
+export async function readWorkspaceAdditionalDirs(
+ deps: WorkspaceLocalDeps,
+ workDir: string,
+): Promise {
+ const projectRoot = await findProjectRoot(deps, workDir);
+ const configPath = getWorkspaceLocalConfigPath(projectRoot);
+ const file = await readWorkspaceLocalToml(deps, configPath);
+
+ const additionalDirs = file?.parsed.workspace?.additional_dir;
+ if (additionalDirs === undefined) {
+ return { projectRoot, configPath, additionalDirs: [] };
+ }
+
+ return {
+ projectRoot,
+ configPath,
+ additionalDirs: await resolveAdditionalDirs(deps, projectRoot, additionalDirs),
+ };
+}
+
+export async function resolveWorkspaceAdditionalDirs(
+ deps: WorkspaceLocalDeps,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): Promise {
+ return resolveAdditionalDirs(deps, projectRoot, additionalDirs);
+}
+
+export async function appendWorkspaceAdditionalDir(
+ deps: WorkspaceLocalDeps,
+ workDir: string,
+ inputPath: string,
+): Promise {
+ const projectRoot = await findProjectRoot(deps, workDir);
+ const configPath = getWorkspaceLocalConfigPath(projectRoot);
+ const additionalDir = await resolveAdditionalDir(deps, workDir, inputPath);
+ const file = (await readWorkspaceLocalToml(deps, configPath)) ?? { raw: {}, parsed: {} };
+ const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? [];
+ const fileExistingDirs = resolveExistingAdditionalDirs(deps, projectRoot, fileAdditionalDirs);
+
+ if (hasSameAdditionalDir(fileExistingDirs, additionalDir)) {
+ return { projectRoot, configPath, additionalDirs: fileExistingDirs };
+ }
+
+ const workspace = cloneRecord(file.raw['workspace']);
+ workspace['additional_dir'] = [...fileExistingDirs, additionalDir];
+ file.raw['workspace'] = workspace;
+
+ await deps.fs.mkdir(dirname(configPath), { recursive: true });
+ await deps.fs.writeText(configPath, `${stringifyToml(file.raw)}\n`);
+
+ return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] };
+}
+
+export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] {
+ const seen = new Set();
+ const normalizedDirs: string[] = [];
+
+ for (const additionalDir of additionalDirs) {
+ const normalized = normalize(additionalDir);
+ if (seen.has(normalized)) continue;
+ seen.add(normalized);
+ normalizedDirs.push(normalized);
+ }
+
+ return normalizedDirs;
+}
+
+function getWorkspaceLocalConfigPath(projectRoot: string): string {
+ return join(projectRoot, '.kimi-code', 'local.toml');
+}
+
+async function findProjectRoot(deps: WorkspaceLocalDeps, workDir: string): Promise {
+ const initial = normalize(workDir);
+ let current = initial;
+
+ while (true) {
+ if (await pathExists(deps, join(current, '.git'))) return current;
+ const parent = dirname(current);
+ if (parent === current) return initial;
+ current = parent;
+ }
+}
+
+async function readWorkspaceLocalToml(
+ deps: WorkspaceLocalDeps,
+ configPath: string,
+): Promise {
+ let text: string;
+ try {
+ text = await deps.fs.readText(configPath);
+ } catch (error: unknown) {
+ if (isPathMissing(error)) return undefined;
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ `Failed to read ${configPath}: ${describeError(error)}`,
+ { cause: error },
+ );
+ }
+
+ if (text.trim().length === 0) return { raw: {}, parsed: {} };
+
+ let raw: unknown;
+ try {
+ raw = parseToml(text);
+ } catch (error: unknown) {
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ `Invalid TOML in ${configPath}: ${describeError(error)}`,
+ { cause: error },
+ );
+ }
+
+ if (!isPlainObject(raw)) {
+ throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid workspace local config in ${configPath}`);
+ }
+
+ return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) };
+}
+
+function parseWorkspaceLocalToml(raw: Record): WorkspaceLocalToml {
+ try {
+ return WorkspaceLocalTomlSchema.parse(raw);
+ } catch (error: unknown) {
+ if (error instanceof z.ZodError) {
+ throw new KimiError(ErrorCodes.CONFIG_INVALID, describeWorkspaceLocalValidationError(error), {
+ cause: error,
+ });
+ }
+ throw error;
+ }
+}
+
+function describeWorkspaceLocalValidationError(error: z.ZodError): string {
+ const issue = error.issues[0];
+ if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') {
+ return 'workspace.additional_dir must be an array of strings';
+ }
+ if (issue?.path[0] === 'workspace') return 'workspace must be a table';
+ return `Invalid workspace local config: ${error.message}`;
+}
+
+async function resolveAdditionalDirs(
+ deps: WorkspaceLocalDeps,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): Promise {
+ const resolvedDirs: string[] = [];
+
+ for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
+ const resolvedDir = await resolveAdditionalDir(deps, projectRoot, additionalDir);
+ if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
+ resolvedDirs.push(resolvedDir);
+ }
+
+ return resolvedDirs;
+}
+
+function resolveExistingAdditionalDirs(
+ deps: WorkspaceLocalDeps,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): string[] {
+ const resolvedDirs: string[] = [];
+
+ for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
+ const resolvedDir = resolvePath(deps, projectRoot, additionalDir);
+ if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
+ resolvedDirs.push(resolvedDir);
+ }
+
+ return resolvedDirs;
+}
+
+async function resolveAdditionalDir(
+ deps: WorkspaceLocalDeps,
+ projectRoot: string,
+ additionalDir: string,
+): Promise {
+ const normalizedInput = normalizeAdditionalDirInput(additionalDir);
+ const resolvedDir = resolvePath(deps, projectRoot, normalizedInput);
+ await assertDirectory(deps, resolvedDir);
+ return resolvedDir;
+}
+
+function normalizeAdditionalDirInput(additionalDir: string): string {
+ if (typeof additionalDir !== 'string') {
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ 'workspace.additional_dir must be an array of strings',
+ );
+ }
+ const trimmed = additionalDir.trim();
+ if (trimmed.length === 0) {
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ }
+ return normalize(trimmed);
+}
+
+function resolvePath(deps: WorkspaceLocalDeps, projectRoot: string, additionalDir: string): string {
+ const expanded = expandHome(deps, additionalDir);
+ return isAbsolute(expanded) ? normalize(expanded) : resolve(projectRoot, expanded);
+}
+
+function expandHome(deps: WorkspaceLocalDeps, value: string): string {
+ if (value === '~') return deps.homeDir;
+ if (value.startsWith('~/')) return join(deps.homeDir, value.slice(2));
+ return value;
+}
+
+function hasSameAdditionalDir(dirs: readonly string[], target: string): boolean {
+ const normalizedTarget = normalize(target);
+ return dirs.some((dir) => normalize(dir) === normalizedTarget);
+}
+
+async function assertDirectory(deps: WorkspaceLocalDeps, filePath: string): Promise {
+ let stat: Awaited>;
+ try {
+ stat = await deps.fs.stat(filePath);
+ } catch (error: unknown) {
+ if (isPathMissing(error)) {
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ }
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ `Failed to stat ${filePath}: ${describeError(error)}`,
+ { cause: error },
+ );
+ }
+
+ if (!stat.isDirectory) {
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ }
+}
+
+async function pathExists(deps: WorkspaceLocalDeps, filePath: string): Promise {
+ try {
+ await deps.fs.stat(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function cloneRecord(value: unknown): Record {
+ if (!isPlainObject(value)) return {};
+ return JSON.parse(JSON.stringify(value)) as Record;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isPathMissing(error: unknown): boolean {
+ const code = getErrorCode(error);
+ return code === 'ENOENT' || code === 'ENOTDIR';
+}
+
+function getErrorCode(error: unknown): unknown {
+ if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
+ return (error as { code: unknown }).code;
+}
+
+function describeError(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts
index 4694d68d8..0919627a0 100644
--- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts
+++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts
@@ -17,6 +17,7 @@ export interface ISessionWorkspaceContext {
readonly workDir: string;
readonly additionalDirs: readonly string[];
setWorkDir(workDir: string): void;
+ setAdditionalDirs(dirs: readonly string[]): void;
resolve(rel: string): string;
isWithin(absPath: string): boolean;
assertAllowed(absPath: string, op: PathAccessOperation): string;
diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
index c0b5d5c3c..a84af00b9 100644
--- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
+++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
@@ -35,6 +35,10 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext
this._workDir = resolve(workDir);
}
+ setAdditionalDirs(dirs: readonly string[]): void {
+ this._additionalDirs = [...new Set(dirs.map((d) => resolve(d)))];
+ }
+
resolve(rel: string): string {
return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel);
}
diff --git a/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts b/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts
index a7be4a81e..adbc0faf5 100644
--- a/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts
+++ b/packages/agent-core-v2/test/fileTools/stub-workspace-context.ts
@@ -16,6 +16,7 @@ export function stubWorkspaceContext(
workDir,
additionalDirs,
setWorkDir: () => {},
+ setAdditionalDirs: () => {},
resolve: (rel) => `${workDir}/${rel}`,
isWithin: () => true,
assertAllowed: (absPath) => absPath,
diff --git a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
index 65832cfec..0051fee43 100644
--- a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
+++ b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
@@ -739,6 +739,9 @@ function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext {
setWorkDir: (nextWorkDir) => {
workDir = nextWorkDir;
},
+ setAdditionalDirs: (dirs) => {
+ additionalDirs = [...dirs];
+ },
resolve: (path) => path,
isWithin: () => true,
assertAllowed: (path) => path,
diff --git a/packages/agent-core-v2/test/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/sessionFs/fsService.test.ts
index 8ecebb3cd..59960bbb4 100644
--- a/packages/agent-core-v2/test/sessionFs/fsService.test.ts
+++ b/packages/agent-core-v2/test/sessionFs/fsService.test.ts
@@ -27,6 +27,7 @@ function stubWorkspace(): ISessionWorkspaceContext {
workDir: WORK_DIR,
additionalDirs: [],
setWorkDir: () => {},
+ setAdditionalDirs: () => {},
resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)),
isWithin: (abs) => {
const r = relative(WORK_DIR, abs);
diff --git a/packages/agent-core-v2/test/skill/skillCatalog.test.ts b/packages/agent-core-v2/test/skill/skillCatalog.test.ts
index 7df173c31..93d7b05df 100644
--- a/packages/agent-core-v2/test/skill/skillCatalog.test.ts
+++ b/packages/agent-core-v2/test/skill/skillCatalog.test.ts
@@ -55,6 +55,7 @@ function workspaceStub(workDir: string): {
setWorkDir: (dir: string) => {
current = dir;
},
+ setAdditionalDirs: () => {},
resolve: (rel: string) => rel,
isWithin: () => true,
assertAllowed: (p: string) => p,
diff --git a/packages/agent-core-v2/test/terminal/terminalService.test.ts b/packages/agent-core-v2/test/terminal/terminalService.test.ts
index e288cbd00..43912758c 100644
--- a/packages/agent-core-v2/test/terminal/terminalService.test.ts
+++ b/packages/agent-core-v2/test/terminal/terminalService.test.ts
@@ -74,6 +74,7 @@ function stubWorkspace(workDir = '/ws'): ISessionWorkspaceContext {
workDir,
additionalDirs: [],
setWorkDir: () => {},
+ setAdditionalDirs: () => {},
resolve: (rel) => resolve(workDir, rel),
isWithin: () => true,
assertAllowed: (absPath) => resolve(workDir, absPath),
diff --git a/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts b/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts
new file mode 100644
index 000000000..f93661932
--- /dev/null
+++ b/packages/agent-core-v2/test/workspaceCommand/workspace-command.test.ts
@@ -0,0 +1,373 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { DisposableStore } from '#/_base/di/lifecycle';
+import type { IAgentScopeHandle } from '#/_base/di/scope';
+import { LifecycleScope } from '#/_base/di/scope';
+import type { ServiceIdentifier } from '#/_base/di/instantiation';
+import { createServices, type TestInstantiationService } from '#/_base/di/test';
+import { Emitter } from '#/_base/event';
+import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
+import { IBootstrapService } from '#/app/bootstrap';
+import { ErrorCodes, KimiError } from '#/errors';
+import {
+ type HostDirEntry,
+ type HostFileStat,
+ IHostFileSystem,
+} from '#/os/interface/hostFileSystem';
+import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
+import { ISessionContext, makeSessionContext } from '#/session/sessionContext';
+import {
+ ISessionWorkspaceCommandService,
+ SessionWorkspaceCommandService,
+} from '#/session/workspaceCommand';
+import { ISessionWorkspaceContext } from '#/session/workspaceContext';
+import { SessionWorkspaceContextService } from '#/session/workspaceContext';
+
+import { stubContextMemory, type StubContextMemory } from '../contextMemory/stubs';
+
+const WORK_DIR = '/repo/work';
+const EXTRA_DIR = `${WORK_DIR}/extra`;
+const DIR_A = `${WORK_DIR}/a`;
+const DIR_B = `${WORK_DIR}/b`;
+
+class MemoryHostFs implements IHostFileSystem {
+ declare readonly _serviceBrand: undefined;
+ readonly files = new Map();
+ readonly dirs = new Set();
+ readonly readsDuringPausedWrite: string[] = [];
+ private pausedWrites = 0;
+ private nextWritePause:
+ | {
+ readonly started: () => void;
+ readonly release: Promise;
+ }
+ | undefined;
+
+ constructor(seedDirs: readonly string[] = []) {
+ for (const d of seedDirs) this.dirs.add(d);
+ }
+
+ async readText(path: string): Promise {
+ if (this.pausedWrites > 0) this.readsDuringPausedWrite.push(path);
+ const text = this.files.get(path);
+ if (text === undefined) throw enoent(path);
+ return text;
+ }
+
+ async writeText(path: string, data: string): Promise {
+ const pause = this.nextWritePause;
+ if (pause !== undefined) {
+ this.nextWritePause = undefined;
+ this.pausedWrites++;
+ pause.started();
+ try {
+ await pause.release;
+ } finally {
+ this.pausedWrites--;
+ }
+ }
+ this.files.set(path, data);
+ }
+
+ pauseNextWrite(): { readonly started: Promise; readonly release: () => void } {
+ let started!: () => void;
+ let release!: () => void;
+ const startedPromise = new Promise((resolve) => {
+ started = resolve;
+ });
+ const releasePromise = new Promise((resolve) => {
+ release = resolve;
+ });
+ this.nextWritePause = { started, release: releasePromise };
+ return { started: startedPromise, release };
+ }
+
+ async readBytes(): Promise {
+ throw new Error('not implemented');
+ }
+
+ async writeBytes(): Promise {
+ throw new Error('not implemented');
+ }
+
+ async *readLines(): AsyncGenerator {
+ throw new Error('not implemented');
+ }
+
+ async createExclusive(): Promise {
+ throw new Error('not implemented');
+ }
+
+ async stat(path: string): Promise {
+ if (this.files.has(path)) {
+ return { isFile: true, isDirectory: false, size: this.files.get(path)?.length ?? 0 };
+ }
+ if (this.dirs.has(path)) return { isFile: false, isDirectory: true, size: 0 };
+ throw enoent(path);
+ }
+
+ async readdir(): Promise {
+ throw new Error('not implemented');
+ }
+
+ async mkdir(path: string): Promise {
+ this.dirs.add(path);
+ }
+
+ async remove(path: string): Promise {
+ this.files.delete(path);
+ this.dirs.delete(path);
+ }
+}
+
+function enoent(path: string): NodeJS.ErrnoException {
+ const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException;
+ error.code = 'ENOENT';
+ return error;
+}
+
+interface AgentsStub extends IAgentLifecycleService {
+ readonly mainContext: StubContextMemory;
+ setMain(present: boolean): void;
+}
+
+function agentsStub(): AgentsStub {
+ const mainContext = stubContextMemory();
+ let mainPresent = false;
+ const mainCreated = new Emitter();
+
+ const mainHandle: IAgentScopeHandle = {
+ id: MAIN_AGENT_ID,
+ kind: LifecycleScope.Agent,
+ accessor: {
+ get: (id: ServiceIdentifier): T => {
+ if (id === IAgentContextMemoryService) return mainContext as unknown as T;
+ throw new Error(`unexpected service on main handle: ${String(id)}`);
+ },
+ },
+ dispose: () => {},
+ };
+
+ return {
+ _serviceBrand: undefined,
+ mainContext,
+ onDidCreate: () => ({ dispose: () => {} }),
+ onDidCreateMain: mainCreated.event,
+ onDidDispose: () => ({ dispose: () => {} }),
+ create: () => Promise.reject(new Error('not implemented')),
+ ensureMcpReady: () => Promise.resolve(),
+ notifyMainCreated: (handle) => mainCreated.fire(handle),
+ fork: () => Promise.reject(new Error('not implemented')),
+ run: () => {
+ throw new Error('not implemented');
+ },
+ getHandle: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined),
+ list: () => [],
+ remove: () => Promise.resolve(),
+ setMain: (present) => {
+ mainPresent = present;
+ if (present) mainCreated.fire(mainHandle);
+ },
+ };
+}
+
+function bootstrapStub(): IBootstrapService {
+ return {
+ _serviceBrand: undefined,
+ homeDir: '/home/test',
+ } as IBootstrapService;
+}
+
+function sessionContext(workDir = WORK_DIR): ISessionContext {
+ return makeSessionContext({
+ sessionId: 'ses',
+ workspaceId: 'ws',
+ sessionDir: '/tmp/sessions/ws/ses',
+ sessionScope: 'sessions/ws/ses',
+ cwd: workDir,
+ });
+}
+
+function nextMacrotask(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+interface Harness {
+ readonly svc: ISessionWorkspaceCommandService;
+ readonly fs: MemoryHostFs;
+ readonly agents: AgentsStub;
+ readonly workspace: ISessionWorkspaceContext;
+}
+
+describe('SessionWorkspaceCommandService', () => {
+ let disposables: DisposableStore;
+ let ix: TestInstantiationService;
+
+ beforeEach(() => {
+ disposables = new DisposableStore();
+ });
+
+ afterEach(() => {
+ disposables.dispose();
+ });
+
+ function build(
+ seedDirs: readonly string[],
+ mainPresent: boolean,
+ workDir = WORK_DIR,
+ gitDir = `${workDir}/.git`,
+ ): Harness {
+ const fs = new MemoryHostFs([gitDir, workDir, ...seedDirs]);
+ const agents = agentsStub();
+ const ctx = sessionContext(workDir);
+
+ ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ reg.defineInstance(ISessionContext, ctx);
+ reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService);
+ reg.defineInstance(IBootstrapService, bootstrapStub());
+ reg.defineInstance(IHostFileSystem, fs);
+ reg.defineInstance(IAgentLifecycleService, agents);
+ reg.define(ISessionWorkspaceCommandService, SessionWorkspaceCommandService);
+ },
+ });
+
+ const workspace = ix.get(ISessionWorkspaceContext);
+ const svc = ix.get(ISessionWorkspaceCommandService);
+ agents.setMain(mainPresent);
+ return { svc, fs, agents, workspace };
+ }
+
+ it('persists the directory and injects a local-command-stdout message when main exists', async () => {
+ const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
+
+ const result = await svc.addAdditionalDir({ path: 'extra', persist: true });
+
+ expect(result.persisted).toBe(true);
+ expect(result.configPath).toBe(`${WORK_DIR}/.kimi-code/local.toml`);
+ expect(result.additionalDirs).toContain(EXTRA_DIR);
+ expect(workspace.additionalDirs).toContain(EXTRA_DIR);
+
+ const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
+ expect(written).toContain('additional_dir');
+ expect(written).toContain(EXTRA_DIR);
+
+ expect(agents.mainContext.messages).toHaveLength(1);
+ expect(agents.mainContext.messages[0]?.content).toEqual([
+ {
+ type: 'text',
+ text: `\nAdded workspace directory:\n extra\n Saved to:\n ${WORK_DIR}/.kimi-code/local.toml\n`,
+ },
+ ]);
+ expect(agents.mainContext.messages[0]?.origin).toEqual({
+ kind: 'injection',
+ variant: 'local-command-stdout',
+ });
+ });
+
+ it('does not persist and injects a session-only message when persist is false', async () => {
+ const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
+
+ const result = await svc.addAdditionalDir({ path: 'extra', persist: false });
+
+ expect(result.persisted).toBe(false);
+ expect(workspace.additionalDirs).toContain(EXTRA_DIR);
+ expect(fs.files.has(`${WORK_DIR}/.kimi-code/local.toml`)).toBe(false);
+
+ expect(agents.mainContext.messages).toHaveLength(1);
+ expect(agents.mainContext.messages[0]?.content).toEqual([
+ {
+ type: 'text',
+ text: '\nAdded workspace directory:\n extra\n For this session only\n',
+ },
+ ]);
+ });
+
+ it('queues the injection until the main agent is created', async () => {
+ const { svc, agents } = build([EXTRA_DIR], false);
+
+ await svc.addAdditionalDir({ path: 'extra', persist: true });
+ expect(agents.mainContext.messages).toHaveLength(0);
+
+ agents.setMain(true);
+
+ expect(agents.mainContext.messages).toHaveLength(1);
+ expect(agents.mainContext.messages[0]?.content[0]).toMatchObject({
+ type: 'text',
+ text: expect.stringContaining('Added workspace directory:'),
+ });
+ });
+
+ it('keeps the persisted config idempotent when the same dir is added twice', async () => {
+ const { svc, fs } = build([EXTRA_DIR], true);
+
+ await svc.addAdditionalDir({ path: 'extra', persist: true });
+ await svc.addAdditionalDir({ path: 'extra', persist: true });
+
+ const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
+ expect(written).toBeDefined();
+ const matches = written?.match(new RegExp(EXTRA_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
+ expect(matches).toHaveLength(1);
+ });
+
+ it('serializes concurrent persisted additions so local.toml keeps both directories', async () => {
+ const { svc, fs, workspace } = build([DIR_A, DIR_B], true);
+ const pause = fs.pauseNextWrite();
+
+ const first = svc.addAdditionalDir({ path: 'a', persist: true });
+ await pause.started;
+
+ const second = svc.addAdditionalDir({ path: 'b', persist: true });
+ await nextMacrotask();
+ const overlappingReads = [...fs.readsDuringPausedWrite];
+
+ pause.release();
+ const [, secondResult] = await Promise.all([first, second]);
+
+ expect(overlappingReads).toEqual([]);
+ expect(secondResult.additionalDirs).toEqual([DIR_A, DIR_B]);
+ expect(workspace.additionalDirs).toEqual([DIR_A, DIR_B]);
+
+ const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
+ expect(written).toContain(DIR_A);
+ expect(written).toContain(DIR_B);
+ });
+
+ it('resolves caller-relative dirs against the session workDir when project root is above it', async () => {
+ const projectRoot = '/repo/project';
+ const workDir = `${projectRoot}/apps/foo`;
+ const sharedDir = `${workDir}/shared`;
+ const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
+
+ const result = await svc.addAdditionalDir({ path: 'shared', persist: true });
+
+ expect(result.projectRoot).toBe(projectRoot);
+ expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
+ expect(result.additionalDirs).toEqual([sharedDir]);
+ expect(workspace.additionalDirs).toEqual([sharedDir]);
+ expect(fs.files.get(`${projectRoot}/.kimi-code/local.toml`)).toContain(sharedDir);
+ });
+
+ it('resolves session-only relative dirs against the session workDir when project root is above it', async () => {
+ const projectRoot = '/repo/project';
+ const workDir = `${projectRoot}/apps/foo`;
+ const sharedDir = `${workDir}/shared`;
+ const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
+
+ const result = await svc.addAdditionalDir({ path: 'shared', persist: false });
+
+ expect(result.projectRoot).toBe(projectRoot);
+ expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
+ expect(result.additionalDirs).toEqual([sharedDir]);
+ expect(workspace.additionalDirs).toEqual([sharedDir]);
+ expect(fs.files.has(`${projectRoot}/.kimi-code/local.toml`)).toBe(false);
+ });
+
+ it('rejects a relative path that does not resolve to an existing directory', async () => {
+ const { svc } = build([], true);
+
+ await expect(svc.addAdditionalDir({ path: 'missing', persist: true })).rejects.toSatisfy(
+ (error) => error instanceof KimiError && error.code === ErrorCodes.CONFIG_INVALID,
+ );
+ });
+});
diff --git a/packages/server-v2/src/transport/actionMap.ts b/packages/server-v2/src/transport/actionMap.ts
index 6120af5a3..a46b4e63c 100644
--- a/packages/server-v2/src/transport/actionMap.ts
+++ b/packages/server-v2/src/transport/actionMap.ts
@@ -53,6 +53,7 @@ import {
IAgentToolState,
IAgentUsageService,
ISessionWorkspaceContext,
+ ISessionWorkspaceCommandService,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
@@ -152,7 +153,10 @@ export const actionMap: Record> = {
'workspace:resolve': { service: ISessionWorkspaceContext, method: 'resolve', readonly: true },
'workspace:isWithin': { service: ISessionWorkspaceContext, method: 'isWithin', readonly: true },
'workspace:setWorkDir': { service: ISessionWorkspaceContext, method: 'setWorkDir' },
- 'workspace:addAdditionalDir': { service: ISessionWorkspaceContext, method: 'addAdditionalDir' },
+ 'workspace:addAdditionalDir': {
+ service: ISessionWorkspaceCommandService,
+ method: 'addAdditionalDir',
+ },
'workspace:removeAdditionalDir': { service: ISessionWorkspaceContext, method: 'removeAdditionalDir' },
'fs:search': { service: ISessionFsService, method: 'search', readonly: true },