diff --git a/.changeset/add-dir-workspace.md b/.changeset/add-dir-workspace.md
new file mode 100644
index 000000000..5761342c8
--- /dev/null
+++ b/.changeset/add-dir-workspace.md
@@ -0,0 +1,7 @@
+---
+"@moonshot-ai/agent-core": minor
+"@moonshot-ai/kimi-code-sdk": minor
+"@moonshot-ai/kimi-code": minor
+---
+
+Add /add-dir workspace directory management with session-only and project-remembered additional directories.
diff --git a/.gitignore b/.gitignore
index 691c675b7..58fdb8953 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ coverage/
plugins/cdn/
superpowers
.worktrees/
+.kimi-code/local.toml
diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts
index faf1e1da8..7121402a6 100644
--- a/apps/kimi-code/src/cli/commands.ts
+++ b/apps/kimi-code/src/cli/commands.ts
@@ -71,6 +71,14 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
+ .addOption(
+ new Option(
+ '--add-dir
',
+ 'Add an additional workspace directory for this session. Can be repeated.',
+ )
+ .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
+ .default([]),
+ )
.addOption(new Option('--yes').hideHelp().default(false))
.addOption(new Option('--auto-approve').hideHelp().default(false))
.option('--plan', 'Start in plan mode.', false);
@@ -119,6 +127,7 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
+ addDirs: raw['addDir'] as string[],
};
onMain(opts);
diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts
index 98f4cb196..2e0c33a42 100644
--- a/apps/kimi-code/src/cli/options.ts
+++ b/apps/kimi-code/src/cli/options.ts
@@ -11,6 +11,7 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
+ addDirs?: string[];
}
export interface ValidatedOptions {
diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts
index f7cef067d..3eecc62b6 100644
--- a/apps/kimi-code/src/cli/run-prompt.ts
+++ b/apps/kimi-code/src/cli/run-prompt.ts
@@ -243,7 +243,10 @@ async function resolvePromptSession(
`Session "${opts.session}" was created under a different directory.`,
);
}
- const session = await harness.resumeSession({ id: opts.session });
+ const session = await harness.resumeSession({
+ id: opts.session,
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
+ });
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@@ -267,7 +270,10 @@ async function resolvePromptSession(
const sessions = await harness.listSessions({ workDir });
const previous = sessions[0];
if (previous !== undefined) {
- const session = await harness.resumeSession({ id: previous.id });
+ const session = await harness.resumeSession({
+ id: previous.id,
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
+ });
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@@ -290,7 +296,12 @@ async function resolvePromptSession(
}
const model = requireConfiguredModel(opts.model, defaultModel);
- const session = await harness.createSession({ workDir, model, permission: 'auto' });
+ const session = await harness.createSession({
+ workDir,
+ model,
+ permission: 'auto',
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
+ });
installHeadlessHandlers(session);
return {
session,
diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts
index e5bdfef24..dcc4d3ca6 100644
--- a/apps/kimi-code/src/cli/run-shell.ts
+++ b/apps/kimi-code/src/cli/run-shell.ts
@@ -98,6 +98,7 @@ export async function runShell(
const configMs = Date.now() - configStartedAt;
const tui = new KimiTUI(harness, {
cliOptions: opts,
+ additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
tuiConfig,
version,
workDir,
diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts
new file mode 100644
index 000000000..0c8543863
--- /dev/null
+++ b/apps/kimi-code/src/tui/commands/add-dir.ts
@@ -0,0 +1,106 @@
+import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
+import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
+import { nextTranscriptId } from '../utils/transcript-id';
+import type { SlashCommandHost } from './dispatch';
+
+type AddDirChoice = 'session' | 'remember' | 'cancel';
+
+export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise {
+ const input = args.trim();
+ const session = host.session;
+
+ if (input.length === 0 || input.toLowerCase() === 'list') {
+ const additionalDirs = session?.summary?.additionalDirs ?? [];
+ if (additionalDirs.length === 0) {
+ host.showStatus('No additional directories configured.');
+ return;
+ }
+ host.showStatus(formatAdditionalDirsStatus(additionalDirs));
+ return;
+ }
+
+ if (session === undefined) {
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
+ return;
+ }
+
+ host.mountEditorReplacement(
+ new ChoicePickerComponent({
+ title: `Add directory to workspace: ${input}`,
+ hint: '↑↓ navigate · Enter confirm · Esc cancel',
+ options: [
+ {
+ value: 'session',
+ label: 'Yes, for this session',
+ },
+ {
+ value: 'remember',
+ label: 'Yes, and remember this directory',
+ },
+ {
+ value: 'cancel',
+ label: 'No',
+ },
+ ],
+ onSelect: (value) => {
+ void handleAddDirChoice(host, session.id, input, value as AddDirChoice);
+ },
+ onCancel: () => {
+ host.restoreEditor();
+ host.showStatus(`Did not add ${input} as a working directory.`);
+ },
+ }),
+ );
+}
+
+function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string {
+ return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n');
+}
+
+function formatAddDirMessage(path: string, choice: AddDirChoice, configPath: string): string {
+ return choice === 'remember'
+ ? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
+ : `Added workspace directory:\n ${path}\n For this session only`;
+}
+
+async function handleAddDirChoice(
+ host: SlashCommandHost,
+ sessionId: string,
+ path: string,
+ choice: AddDirChoice,
+): Promise {
+ host.restoreEditor();
+
+ if (choice === 'cancel') {
+ host.showStatus(`Did not add ${path} as a working directory.`);
+ return;
+ }
+
+ const session = host.session;
+ if (session === undefined || session.id !== sessionId) {
+ host.showError(NO_ACTIVE_SESSION_MESSAGE);
+ return;
+ }
+
+ try {
+ const result = await session.addAdditionalDir(path, { persist: choice === 'remember' });
+ host.setAppState({ additionalDirs: result.additionalDirs });
+ host.refreshSlashCommandAutocomplete();
+ const message = formatAddDirMessage(path, choice, result.configPath);
+ await session.appendUserMessage(message);
+ host.appendTranscriptEntry({
+ id: nextTranscriptId(),
+ kind: 'user',
+ renderMode: 'plain',
+ content: message,
+ });
+ host.showStatus(
+ choice === 'remember'
+ ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}`
+ : `Added workspace directory:\n ${path}\n For this session only`,
+ 'success',
+ );
+ } catch (error) {
+ host.showError(error instanceof Error ? error.message : String(error));
+ }
+}
diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts
index ed67da39c..a50c66262 100644
--- a/apps/kimi-code/src/tui/commands/dispatch.ts
+++ b/apps/kimi-code/src/tui/commands/dispatch.ts
@@ -45,6 +45,7 @@ import {
import { handleGoalCommand } from './goal';
import { handleProviderCommand } from './provider';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
+import { handleAddDirCommand } from './add-dir';
import { handlePluginsCommand } from './plugins';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import { handleSwarmCommand } from './swarm';
@@ -66,6 +67,7 @@ export {
handleLogoutCommand,
} from './auth';
export { handleBtwCommand } from './btw';
+export { handleAddDirCommand } from './add-dir';
export {
handleAutoCommand,
handleCompactCommand,
@@ -257,6 +259,9 @@ async function handleBuiltInSlashCommand(
case 'plugins':
void handlePluginsCommand(host, args);
return;
+ case 'add-dir':
+ await handleAddDirCommand(host, args);
+ return;
case 'experiments':
await showExperimentsPanel(host);
return;
diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts
index 464cc770d..21c223d6a 100644
--- a/apps/kimi-code/src/tui/commands/registry.ts
+++ b/apps/kimi-code/src/tui/commands/registry.ts
@@ -1,3 +1,7 @@
+import { readdirSync, statSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { basename, dirname, join, relative, resolve } from 'pathe';
+
import type { AutocompleteItem } from '@earendil-works/pi-tui';
import { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
@@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'off', description: 'Turn swarm mode off' },
];
+const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
+ { value: 'list', description: 'Show configured additional workspace directories' },
+];
+
/** Argument autocompletion for the `/goal` command (subcommands). */
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
@@ -41,6 +49,89 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt
return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
}
+/** Argument autocompletion for the `/add-dir` command. */
+export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
+ if (isPathLikeAddDirArgument(argumentPrefix)) {
+ return completeAddDirPath(argumentPrefix);
+ }
+ return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix);
+}
+
+function isPathLikeAddDirArgument(argumentPrefix: string): boolean {
+ return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~');
+}
+
+function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null {
+ const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix;
+ const expandedPrefix = expandHomePrefix(normalizedPrefix);
+ const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix);
+ const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix);
+ const parentDir = resolveDirectoryCompletionParent(parentInput);
+ let entries;
+ try {
+ entries = readdirSync(parentDir, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+
+ const items: AutocompleteItem[] = [];
+ for (const entry of entries) {
+ if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue;
+ if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue;
+ const absolutePath = join(parentDir, entry.name);
+ if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue;
+ const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name);
+ items.push({
+ value,
+ label: `${entry.name}/`,
+ description: absolutePath,
+ });
+ }
+
+ return items.length > 0 ? items : null;
+}
+
+function expandHomePrefix(argumentPrefix: string): string {
+ if (argumentPrefix === '~') return homedir();
+ if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2));
+ return argumentPrefix;
+}
+
+function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string {
+ if (argumentPrefix === '/') return '/';
+ if (argumentPrefix === '~/') return homedir();
+ if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1);
+ return dirname(expandedPrefix);
+}
+
+function resolveDirectoryCompletionParent(parentInput: string): string {
+ if (parentInput === '~') return homedir();
+ if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2));
+ return resolve(parentInput);
+}
+
+function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean {
+ if (isDirectory) return true;
+ if (!isSymlink) return false;
+ try {
+ return statSync(path).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string {
+ if (argumentPrefix.startsWith('~/')) {
+ const home = homedir();
+ const homeRelative = relative(home, parentInput);
+ return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`;
+ }
+ if (argumentPrefix.startsWith('/')) {
+ return `${join(parentInput, entryName)}/`;
+ }
+ return `${join(parentInput, entryName)}/`;
+}
+
export const BUILTIN_SLASH_COMMANDS = [
{
name: 'yolo',
@@ -146,6 +237,15 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
+ {
+ name: 'add-dir',
+ aliases: [],
+ description: 'Add or list an additional workspace directory',
+ priority: 60,
+ availability: 'idle-only',
+ argumentHint: '[list] | ',
+ completeArgs: addDirArgumentCompletions,
+ },
{
name: 'experiments',
aliases: ['experimental'],
diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
index c03444f9b..24098cd59 100644
--- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
+++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
@@ -129,15 +129,22 @@ export class ChoicePickerComponent extends Container implements Focusable {
const titleSuffix =
searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : '';
+ const hintLines = hint.split(/\r?\n/);
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix,
- this.opts.formatHint === undefined
- ? currentTheme.fg('textMuted', ` ${hint}`)
- : this.opts.formatHint(` ${hint}`),
];
+ for (const hintLine of hintLines) {
+ lines.push(
+ this.opts.formatHint === undefined
+ ? currentTheme.fg('textMuted', ` ${hintLine}`)
+ : this.opts.formatHint(` ${hintLine}`),
+ );
+ }
if (this.opts.notice !== undefined) {
- lines.push(currentTheme.fg('success', ` ${this.opts.notice}`));
+ for (const noticeLine of this.opts.notice.split(/\r?\n/)) {
+ lines.push(currentTheme.fg('success', ` ${noticeLine}`));
+ }
}
lines.push('');
if (searchable && view.query.length > 0) {
diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts
index ded2af648..8950e3bc0 100644
--- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts
+++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts
@@ -359,6 +359,17 @@ export class CustomEditor extends Editor {
}
super.handleInput(normalized);
+ this.reopenSlashArgumentCompletionAfterInput(normalized);
+ }
+
+ private reopenSlashArgumentCompletionAfterInput(data: string): void {
+ if (data !== '/') return;
+ const { line, col } = this.getCursor();
+ const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? '';
+ if (!textBeforeCursor.startsWith('/')) return;
+ if (!textBeforeCursor.includes(' ')) return;
+ (this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void })
+ .requestAutocomplete?.({ force: true, explicitTab: false });
}
}
diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
index fc9dc43be..bcc6fdc34 100644
--- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
+++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
@@ -1,5 +1,5 @@
import { readdirSync, statSync } from 'node:fs';
-import { basename, join } from 'node:path';
+import { basename, join, resolve } from 'node:path';
import {
CombinedAutocompleteProvider,
@@ -20,6 +20,7 @@ export interface SlashAutocompleteCommand extends SlashCommand {
interface FsMentionCandidate {
readonly path: string;
+ readonly absolutePath: string;
readonly isDirectory: boolean;
}
@@ -27,19 +28,24 @@ interface FsMentionCandidate {
* Kimi wrapper around pi-tui's combined autocomplete provider.
*
* File / folder mention behavior uses pi-tui's fd-backed provider when fd is
- * available. While managed fd is downloading (or when it is unavailable), a
- * small filesystem fallback keeps basic `@` file and folder completion usable.
- * Ordinary path completion is still handled by pi-tui's readdir-backed path
- * completer. This wrapper also keeps Kimi-specific slash-command guards.
+ * available and only the current working directory is involved. While managed fd
+ * is downloading, when it is unavailable, or when the session has additional
+ * roots, a small filesystem fallback keeps `@` file and folder completion usable
+ * across every root. Ordinary path completion is still handled by pi-tui's
+ * readdir-backed path completer. This wrapper also keeps Kimi-specific
+ * slash-command guards.
*/
export class FileMentionProvider implements AutocompleteProvider {
private readonly inner: CombinedAutocompleteProvider;
+ private readonly additionalDirs: readonly string[];
constructor(
private readonly slashCommands: SlashAutocompleteCommand[],
private readonly workDir: string,
private readonly fdPath: string | null,
+ additionalDirs: readonly string[] = [],
) {
+ this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir)));
// Build an expanded list that includes alias entries so that
// inner's argument completion can find commands by alias too.
const expanded: SlashAutocompleteCommand[] = [];
@@ -77,14 +83,24 @@ export class FileMentionProvider implements AutocompleteProvider {
const atPrefix = extractAtPrefix(textBeforeCursor);
if (atPrefix !== null) {
- if (this.fdPath === null) {
- return getFsMentionSuggestions(this.workDir, atPrefix, options.signal);
+ if (this.fdPath === null || this.additionalDirs.length > 0) {
+ return getFsMentionSuggestions(
+ this.workDir,
+ this.additionalDirs,
+ atPrefix,
+ options.signal,
+ );
}
try {
return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
} catch {
// If fd fails to spawn unexpectedly, keep @ completion usable.
- return getFsMentionSuggestions(this.workDir, atPrefix, options.signal);
+ return getFsMentionSuggestions(
+ this.workDir,
+ this.additionalDirs,
+ atPrefix,
+ options.signal,
+ );
}
}
@@ -148,6 +164,11 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
+ const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor);
+ if (slashArgumentSuggestions !== null) {
+ return slashArgumentSuggestions;
+ }
+
try {
return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
} catch {
@@ -180,13 +201,14 @@ function extractAtPrefix(text: string): string | null {
function getFsMentionSuggestions(
workDir: string,
+ additionalDirs: readonly string[],
atPrefix: string,
signal: AbortSignal,
): AutocompleteSuggestions | null {
if (signal.aborted) return null;
const query = atPrefix.slice(1);
- const candidates = collectFsMentionCandidates(workDir, signal);
+ const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal);
if (candidates.length === 0 || signal.aborted) return null;
const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS);
@@ -198,44 +220,69 @@ function getFsMentionSuggestions(
};
}
-function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] {
- const result: FsMentionCandidate[] = [];
- const stack = [''];
-
- while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) {
- if (signal.aborted) break;
- const relativeDir = stack.pop() ?? '';
- const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir);
- let entries;
- try {
- entries = readdirSync(absoluteDir, { withFileTypes: true });
- } catch {
- continue;
- }
+function collectFsMentionCandidates(
+ workDir: string,
+ additionalDirs: readonly string[],
+ signal: AbortSignal,
+): FsMentionCandidate[] {
+ const candidatesByAbsolutePath = new Map();
+ const roots = [
+ { root: normalizePath(resolve(workDir)), isAdditionalDir: false },
+ ...additionalDirs.map((dir) => ({
+ root: normalizePath(resolve(workDir, dir)),
+ isAdditionalDir: true,
+ })),
+ ];
+ let scanned = 0;
+
+ for (const { root, isAdditionalDir } of roots) {
+ const stack = [''];
+
+ while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) {
+ if (signal.aborted) break;
+ const relativeDir = stack.pop() ?? '';
+ const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir);
+ let entries;
+ try {
+ entries = readdirSync(absoluteDir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
- for (const entry of entries) {
- if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break;
- if (entry.name === '.git') continue;
-
- const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name));
- const isSymlink = entry.isSymbolicLink();
- let isDirectory = entry.isDirectory();
- if (!isDirectory && isSymlink) {
- try {
- isDirectory = statSync(join(workDir, relativePath)).isDirectory();
- } catch {
- // Broken symlink or permission error — keep it as a file candidate.
+ for (const entry of entries) {
+ if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break;
+ if (entry.name === '.git') continue;
+
+ const relativePath = normalizePath(
+ relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name),
+ );
+ const absolutePath = normalizePath(join(absoluteDir, entry.name));
+ const isSymlink = entry.isSymbolicLink();
+ let isDirectory = entry.isDirectory();
+ if (!isDirectory && isSymlink) {
+ try {
+ isDirectory = statSync(absolutePath).isDirectory();
+ } catch {
+ // Broken symlink or permission error — keep it as a file candidate.
+ }
}
- }
- result.push({ path: relativePath, isDirectory });
- if (isDirectory && !isSymlink) {
- stack.push(relativePath);
+ scanned += 1;
+ if (!candidatesByAbsolutePath.has(absolutePath)) {
+ candidatesByAbsolutePath.set(absolutePath, {
+ path: isAdditionalDir ? absolutePath : relativePath,
+ absolutePath,
+ isDirectory,
+ });
+ }
+ if (isDirectory && !isSymlink) {
+ stack.push(relativePath);
+ }
}
}
}
- return result;
+ return [...candidatesByAbsolutePath.values()];
}
function rankFsMentionCandidates(
@@ -285,7 +332,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem {
return {
value,
label,
- description: valuePath,
+ description: candidate.absolutePath,
};
}
@@ -293,6 +340,52 @@ function normalizePath(path: string): string {
return path.replaceAll('\\', '/');
}
+async function getSlashArgumentSuggestions(
+ slashCommands: readonly SlashAutocompleteCommand[],
+ textBeforeCursor: string,
+): Promise {
+ const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands);
+ if (parsed === null) return null;
+
+ const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix);
+ if (items === undefined || items === null || items.length === 0) return null;
+
+ return {
+ prefix: parsed.argumentPrefix,
+ items,
+ };
+}
+
+function parseSlashArgumentContext(
+ textBeforeCursor: string,
+ slashCommands: readonly SlashAutocompleteCommand[],
+): { command: SlashAutocompleteCommand; argumentPrefix: string } | null {
+ const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/);
+ if (whitespaceMatch !== null) {
+ const [, commandName = '', argumentPrefix = ''] = whitespaceMatch;
+ const command = findSlashCommand(slashCommands, commandName);
+ if (command === undefined) return null;
+ if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null;
+ return { command, argumentPrefix };
+ }
+
+ const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/);
+ const commandName = pathLikeMatch?.[1];
+ const argumentPrefix = pathLikeMatch?.[2];
+ if (commandName === undefined || argumentPrefix === undefined) return null;
+
+ const command = findSlashCommand(slashCommands, commandName);
+ if (command === undefined) return null;
+ return { command, argumentPrefix };
+}
+
+function findSlashCommand(
+ slashCommands: readonly SlashAutocompleteCommand[],
+ commandName: string,
+): SlashAutocompleteCommand | undefined {
+ return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName));
+}
+
function shouldSuppressLeadingWhitespaceSlashPath(
textBeforeCursor: string,
force: boolean | undefined,
diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts
index b0d1cc22d..03199b65a 100644
--- a/apps/kimi-code/src/tui/controllers/auth-flow.ts
+++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts
@@ -1,4 +1,4 @@
-import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
+import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import type { SkillListSession } from '../commands';
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
@@ -11,6 +11,10 @@ import type { SessionEventHandler } from './session-event-handler';
import type { AppState, KimiTUIOptions } from '../types';
import type { TUIState } from '../tui-state';
+type MutableCreateSessionOptions = {
+ -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P];
+};
+
export interface AuthFlowHost {
state: TUIState;
session: Session | undefined;
@@ -67,7 +71,7 @@ export class AuthFlowController {
return;
}
- const session = await host.harness.createSession({
+ const options: MutableCreateSessionOptions = {
workDir: host.state.appState.workDir,
model,
thinking: level,
@@ -77,7 +81,11 @@ export class AuthFlowController {
? 'yolo'
: undefined,
planMode: host.state.appState.planMode ? true : undefined,
- });
+ };
+ if (host.state.appState.additionalDirs.length > 0) {
+ options.additionalDirs = [...host.state.appState.additionalDirs];
+ }
+ const session = await host.harness.createSession(options);
await host.setSession(session);
host.setAppState({
sessionId: session.id,
diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts
index 330f9c7f1..4bd83f9bd 100644
--- a/apps/kimi-code/src/tui/kimi-tui.ts
+++ b/apps/kimi-code/src/tui/kimi-tui.ts
@@ -149,6 +149,7 @@ export type {
export interface KimiTUIStartupInput {
readonly cliOptions: CLIOptions;
+ readonly additionalDirs?: readonly string[];
readonly tuiConfig: TuiConfig;
readonly version: string;
readonly workDir: string;
@@ -160,6 +161,14 @@ export interface KimiTUIStartupInput {
type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session';
+function sameStringArrays(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((value, index) => value === b[index]);
+}
+
+type MutableCreateSessionOptions = {
+ -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P];
+};
+
function createInitialAppState(input: KimiTUIStartupInput): AppState {
const startupPermission: PermissionMode = input.cliOptions.auto
? 'auto'
@@ -169,6 +178,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
return {
model: '',
workDir: input.workDir,
+ additionalDirs: [...(input.additionalDirs ?? [])],
sessionId: '',
permissionMode: startupPermission,
planMode: input.cliOptions.plan,
@@ -334,6 +344,7 @@ export class KimiTUI {
slashCommands,
this.state.appState.workDir,
this.fdPath,
+ this.state.appState.additionalDirs,
);
this.state.editor.setAutocompleteProvider(provider);
}
@@ -557,12 +568,15 @@ export class KimiTUI {
let session: Session | undefined;
let shouldReplayHistory = false;
const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast;
- const createSessionOptions: CreateSessionOptions = {
+ const createSessionOptions: MutableCreateSessionOptions = {
workDir,
model: startup.model,
permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined,
planMode: startup.plan ? true : undefined,
};
+ if (this.state.appState.additionalDirs.length > 0) {
+ createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs];
+ }
try {
if (isResumeStartup) {
@@ -593,13 +607,19 @@ export class KimiTUI {
`Session "${startup.sessionFlag}" was created under a different directory.`,
);
}
- session = await this.harness.resumeSession({ id: startup.sessionFlag });
+ session = await this.harness.resumeSession({
+ id: startup.sessionFlag,
+ additionalDirs: createSessionOptions.additionalDirs,
+ });
shouldReplayHistory = true;
} else {
const sessions = await this.harness.listSessions({ workDir });
const target = sessions[0];
if (target !== undefined) {
- session = await this.harness.resumeSession({ id: target.id });
+ session = await this.harness.resumeSession({
+ id: target.id,
+ additionalDirs: createSessionOptions.additionalDirs,
+ });
shouldReplayHistory = true;
} else {
session = await this.harness.createSession(createSessionOptions);
@@ -1039,6 +1059,9 @@ export class KimiTUI {
setAppState(patch: Partial): void {
if (!hasPatchChanges(this.state.appState, patch)) return;
+ const additionalDirsChanged =
+ 'additionalDirs' in patch &&
+ !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []);
const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch;
Object.assign(this.state.appState, patch);
if ('planMode' in patch) this.updateEditorBorderHighlight();
@@ -1048,6 +1071,7 @@ export class KimiTUI {
this.updateQueueDisplay();
this.sessionEventHandler.retryQueuedGoalPromotion();
}
+ if (additionalDirsChanged) this.setupAutocomplete();
this.state.ui.requestRender();
}
@@ -1064,6 +1088,12 @@ export class KimiTUI {
this.state.ui.requestRender();
}
+ private syncAdditionalDirs(session: Session): void {
+ const additionalDirs = session.summary?.additionalDirs ?? [];
+ if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return;
+ this.setAppState({ additionalDirs: [...additionalDirs] });
+ }
+
// =========================================================================
// Session Runtime
// =========================================================================
@@ -1080,14 +1110,18 @@ export class KimiTUI {
if (model.length === 0) {
throw new Error(LLM_NOT_SET_MESSAGE);
}
- return this.harness.createSession({
+ const options: MutableCreateSessionOptions = {
workDir: this.state.appState.workDir,
model,
thinking:
this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off',
permission: this.state.appState.permissionMode,
planMode: this.state.appState.planMode ? true : undefined,
- });
+ };
+ if (this.state.appState.additionalDirs.length > 0) {
+ options.additionalDirs = [...this.state.appState.additionalDirs];
+ }
+ return this.harness.createSession(options);
}
async setSession(session: Session): Promise {
@@ -1096,6 +1130,7 @@ export class KimiTUI {
this.session = session;
this.harness.setTelemetryContext({ sessionId: session.id });
this.registerSessionHandlers(session);
+ this.syncAdditionalDirs(session);
}
async syncRuntimeState(session: Session = this.requireSession()): Promise {
@@ -1113,6 +1148,7 @@ export class KimiTUI {
sessionTitle: session.summary?.title ?? null,
goal: goalResult.goal,
});
+ this.syncAdditionalDirs(session);
}
// Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed
diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts
index 6b407f777..b8249e92c 100644
--- a/apps/kimi-code/src/tui/types.ts
+++ b/apps/kimi-code/src/tui/types.ts
@@ -26,6 +26,7 @@ export interface BannerState {
export interface AppState {
model: string;
workDir: string;
+ additionalDirs: readonly string[];
sessionId: string;
permissionMode: PermissionMode;
planMode: boolean;
diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts
index e14629e01..e7fec7218 100644
--- a/apps/kimi-code/test/cli/options.test.ts
+++ b/apps/kimi-code/test/cli/options.test.ts
@@ -41,6 +41,7 @@ describe('CLI options parsing', () => {
expect(opts.outputFormat).toBeUndefined();
expect(opts.prompt).toBeUndefined();
expect(opts.skillsDirs).toEqual([]);
+ expect(opts.addDirs).toEqual([]);
});
});
@@ -307,6 +308,16 @@ describe('CLI options parsing', () => {
});
});
+ describe('--add-dir', () => {
+ it('parses one additional workspace directory', () => {
+ expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']);
+ });
+
+ it('parses repeated additional workspace directories', () => {
+ expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']);
+ });
+ });
+
describe('sub-commands', () => {
it('routes upgrade without calling the main action', () => {
let upgradeCalls = 0;
@@ -364,7 +375,6 @@ describe('CLI options parsing', () => {
'--print',
'--wire',
'--agent=default',
- '--add-dir=/',
'--raw-model',
'--config-file=x',
'--quiet',
diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts
index a3620aa35..bc7c59989 100644
--- a/apps/kimi-code/test/cli/run-prompt.test.ts
+++ b/apps/kimi-code/test/cli/run-prompt.test.ts
@@ -135,6 +135,7 @@ function opts(overrides: Partial[0]> = {}) {
outputFormat: undefined,
prompt: 'say hello',
skillsDirs: [],
+ addDirs: [],
...overrides,
};
}
@@ -205,6 +206,7 @@ describe('runPrompt', () => {
workDir: process.cwd(),
model: 'k2',
permission: 'auto',
+ additionalDirs: undefined,
});
expect(mocks.session.setPermission).not.toHaveBeenCalled();
expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function));
@@ -242,12 +244,27 @@ describe('runPrompt', () => {
workDir: process.cwd(),
model: 'kimi-code/k2.5',
permission: 'auto',
+ additionalDirs: undefined,
});
expect(mocks.initializeTelemetry).toHaveBeenCalledWith(
expect.objectContaining({ model: 'kimi-code/k2.5' }),
);
});
+ it('passes the CLI additional directory when creating a fresh prompt session', async () => {
+ await runPrompt(opts({ addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', {
+ stdout: { write: vi.fn(() => true) },
+ stderr: { write: vi.fn(() => true) },
+ });
+
+ expect(mocks.harnessCreateSession).toHaveBeenCalledWith({
+ workDir: process.cwd(),
+ model: 'k2',
+ permission: 'auto',
+ additionalDirs: ['../shared', '/tmp/extra'],
+ });
+ });
+
it('tracks first launch in prompt mode before harness construction can create the device id', async () => {
mocks.harnessCreatesDeviceIdOnConstruction = true;
const createdHomes = new Set();
@@ -442,6 +459,23 @@ describe('runPrompt', () => {
expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual');
});
+ it('passes the CLI additional directories when resuming a concrete session', async () => {
+ await runPrompt(
+ opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }),
+ '1.2.3-test',
+ {
+ stdout: { write: vi.fn(() => true) },
+ stderr: { write: vi.fn(() => true) },
+ },
+ );
+
+ expect(mocks.harnessResumeSession).toHaveBeenCalledWith({
+ id: 'ses_existing',
+ additionalDirs: ['../shared', '/tmp/extra'],
+ });
+ expect(mocks.harnessCreateSession).not.toHaveBeenCalled();
+ });
+
it('allows resuming a concrete session when Windows workdir uses backslashes', async () => {
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`);
mocks.harnessListSessions.mockResolvedValueOnce([
@@ -567,6 +601,19 @@ describe('runPrompt', () => {
expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual');
});
+ it('passes the CLI additional directories when continuing the previous session', async () => {
+ await runPrompt(opts({ continue: true, addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', {
+ stdout: { write: vi.fn(() => true) },
+ stderr: { write: vi.fn(() => true) },
+ });
+
+ expect(mocks.harnessResumeSession).toHaveBeenCalledWith({
+ id: 'ses_previous',
+ additionalDirs: ['../shared', '/tmp/extra'],
+ });
+ expect(mocks.harnessCreateSession).not.toHaveBeenCalled();
+ });
+
it('continues a previous session without a configured default model', async () => {
mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true });
mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' });
diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts
index bab4fb152..106ef0db8 100644
--- a/apps/kimi-code/test/cli/run-shell.test.ts
+++ b/apps/kimi-code/test/cli/run-shell.test.ts
@@ -181,6 +181,7 @@ describe('runShell', () => {
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
+ addDirs: ['../shared', '/tmp/extra'],
};
await runShell(cliOptions, '1.2.3-test');
@@ -219,6 +220,7 @@ describe('runShell', () => {
expect(harness).toBeTypeOf('object');
expect(startupInput).toMatchObject({
cliOptions,
+ additionalDirs: ['../shared', '/tmp/extra'],
tuiConfig: {
theme: 'dark',
editorCommand: null,
diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts
new file mode 100644
index 000000000..31b9cf68f
--- /dev/null
+++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { handleAddDirCommand } from '#/tui/commands/add-dir';
+import { dispatchInput, type SlashCommandHost } from '#/tui/commands/dispatch';
+
+type MountedPanel = {
+ handleInput: (data: string) => void;
+ render: (width: number) => string[];
+};
+
+const ANSI_SGR = /\u001B\[[0-9;]*m/g;
+
+function strip(text: string): string {
+ return text.replaceAll(ANSI_SGR, '');
+}
+
+function makeHost(additionalDirs: readonly string[] = []) {
+ const state = {
+ appState: {
+ additionalDirs,
+ streamingPhase: 'idle',
+ isCompacting: false,
+ },
+ };
+ let mountedPanel: MountedPanel | null = null;
+ const session = {
+ id: 'session-1',
+ summary: {
+ additionalDirs,
+ },
+ addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({
+ additionalDirs: [...additionalDirs, path],
+ projectRoot: '/repo',
+ configPath: '/repo/.kimi-code/local.toml',
+ persisted: options.persist,
+ })),
+ appendUserMessage: vi.fn(async () => {}),
+ };
+ const host = {
+ state,
+ session,
+ skillCommandMap: new Map(),
+ setAppState: vi.fn((patch: Record) => Object.assign(state.appState, patch)),
+ refreshSlashCommandAutocomplete: vi.fn(),
+ appendTranscriptEntry: vi.fn(),
+ showError: vi.fn(),
+ showStatus: vi.fn(),
+ sendNormalUserInput: vi.fn(),
+ track: vi.fn(),
+ mountEditorReplacement: vi.fn((panel: MountedPanel) => {
+ mountedPanel = panel;
+ }),
+ restoreEditor: vi.fn(() => {
+ mountedPanel = null;
+ }),
+ } as unknown as SlashCommandHost & {
+ session: typeof session;
+ state: typeof state;
+ setAppState: ReturnType;
+ refreshSlashCommandAutocomplete: ReturnType;
+ appendTranscriptEntry: ReturnType;
+ showError: ReturnType;
+ showStatus: ReturnType;
+ sendNormalUserInput: ReturnType;
+ mountEditorReplacement: ReturnType;
+ restoreEditor: ReturnType;
+ };
+ return {
+ host,
+ session,
+ getMountedPanel: () => mountedPanel,
+ };
+}
+
+describe('handleAddDirCommand', () => {
+ it('shows the empty message when no additional dirs are configured', async () => {
+ const { host } = makeHost();
+
+ await handleAddDirCommand(host, '');
+
+ expect(host.showStatus).toHaveBeenCalledWith('No additional directories configured.');
+ });
+
+ it('lists current additional dirs for no args', async () => {
+ const { host } = makeHost(['/repo/shared', '/repo/docs']);
+
+ await handleAddDirCommand(host, '');
+
+ expect(host.showStatus).toHaveBeenCalledWith(
+ 'Additional directories:\n /repo/shared\n /repo/docs',
+ );
+ });
+
+ it('lists current additional dirs for the list subcommand', async () => {
+ const { host } = makeHost(['/repo/shared']);
+
+ await handleAddDirCommand(host, 'list');
+
+ expect(host.showStatus).toHaveBeenCalledWith('Additional directories:\n /repo/shared');
+ });
+
+ it('renders the add-dir confirmation without option descriptions', async () => {
+ const { host, getMountedPanel } = makeHost();
+
+ await handleAddDirCommand(host, '../shared');
+
+ const rendered = getMountedPanel()?.render(120).map(strip).join('\n') ?? '';
+ expect(rendered).toContain('Add directory to workspace: ../shared');
+ expect(rendered).toContain('Yes, for this session');
+ expect(rendered).toContain('Yes, and remember this directory');
+ expect(rendered).toContain('No');
+ expect(rendered).not.toContain('Use this directory in the current session only');
+ expect(rendered).not.toContain('Save this directory to the project workspace config');
+ expect(rendered).not.toContain('Do not add this directory.');
+ });
+
+ it('adds a workspace dir for this session only after confirmation', async () => {
+ const { host, session, getMountedPanel } = makeHost();
+
+ await handleAddDirCommand(host, '../shared');
+ getMountedPanel()?.handleInput(' ');
+
+ await vi.waitFor(() => {
+ expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false });
+ });
+ expect(session.appendUserMessage).toHaveBeenCalledWith(
+ 'Added workspace directory:\n ../shared\n For this session only',
+ );
+ expect(host.appendTranscriptEntry).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'user',
+ renderMode: 'plain',
+ content: 'Added workspace directory:\n ../shared\n For this session only',
+ }),
+ );
+ expect(host.restoreEditor).toHaveBeenCalledOnce();
+ expect(host.setAppState).toHaveBeenCalledWith({
+ additionalDirs: ['../shared'],
+ });
+ expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce();
+ await vi.waitFor(() => {
+ expect(host.showStatus).toHaveBeenCalledWith(
+ 'Added workspace directory:\n ../shared\n For this session only',
+ 'success',
+ );
+ });
+ });
+
+ it('adds a remembered workspace dir after confirmation', async () => {
+ const { host, session, getMountedPanel } = makeHost();
+
+ await handleAddDirCommand(host, '../shared');
+ getMountedPanel()?.handleInput('\u001B[B');
+ getMountedPanel()?.handleInput(' ');
+
+ await vi.waitFor(() => {
+ expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true });
+ });
+ expect(session.appendUserMessage).toHaveBeenCalledWith(
+ 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml',
+ );
+ expect(host.appendTranscriptEntry).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'user',
+ renderMode: 'plain',
+ content: 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml',
+ }),
+ );
+ await vi.waitFor(() => {
+ expect(host.showStatus).toHaveBeenCalledWith(
+ 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml',
+ 'success',
+ );
+ });
+ });
+
+ it('does not add a workspace dir when the confirmation is cancelled', async () => {
+ const { host, session, getMountedPanel } = makeHost();
+
+ await handleAddDirCommand(host, '../shared');
+ getMountedPanel()?.handleInput('\u001B[B');
+ getMountedPanel()?.handleInput('\u001B[B');
+ getMountedPanel()?.handleInput(' ');
+
+ expect(session.addAdditionalDir).not.toHaveBeenCalled();
+ expect(session.appendUserMessage).not.toHaveBeenCalled();
+ expect(host.appendTranscriptEntry).not.toHaveBeenCalled();
+ expect(host.showStatus).toHaveBeenCalledWith('Did not add ../shared as a working directory.');
+ });
+
+ it('routes /add-dir errors through the slash-command dispatcher error handler', async () => {
+ const { host, session, getMountedPanel } = makeHost();
+ session.addAdditionalDir.mockRejectedValueOnce(new Error('workspace.additional_dir must exist and be a directory'));
+
+ dispatchInput(host, '/add-dir ../other');
+ await vi.waitFor(() => {
+ expect(getMountedPanel()).not.toBeNull();
+ });
+ getMountedPanel()?.handleInput(' ');
+
+ await vi.waitFor(() => {
+ expect(host.showError).toHaveBeenCalledWith(
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ });
+
+ expect(host.setAppState).not.toHaveBeenCalled();
+ expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled();
+ expect(session.appendUserMessage).not.toHaveBeenCalled();
+ expect(host.appendTranscriptEntry).not.toHaveBeenCalled();
+ expect(host.sendNormalUserInput).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts
index edfeaa106..bc4c5894f 100644
--- a/apps/kimi-code/test/tui/commands/registry.test.ts
+++ b/apps/kimi-code/test/tui/commands/registry.test.ts
@@ -3,6 +3,7 @@ import {
findBuiltInSlashCommand,
parseSlashInput,
resolveSlashCommandAvailability,
+ addDirArgumentCompletions,
sortSlashCommands,
swarmArgumentCompletions,
type KimiSlashCommand,
@@ -73,6 +74,27 @@ describe('built-in slash command registry', () => {
expect(values('Ship feature X')).toBeNull();
});
+ it('offers add-dir list and directory argument completions', () => {
+ const values = (prefix: string): string[] | null => {
+ const items = addDirArgumentCompletions(prefix);
+ return items === null ? null : items.map((item) => item.value);
+ };
+
+ expect(values('')).toEqual(['list']);
+ expect(values('L')).toEqual(['list']);
+ expect(values('list')).toBeNull();
+ const directoryCompletions = values('/') ?? [];
+ expect(directoryCompletions.length).toBeGreaterThan(0);
+ expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true);
+ expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false);
+ expect(values('/.')).toBeNull();
+ const homeCompletions = values('~/') ?? [];
+ expect(homeCompletions.length).toBeGreaterThan(0);
+ expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true);
+ expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false);
+ expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false);
+ });
+
it('defaults commands without explicit availability to idle-only', () => {
const command: KimiSlashCommand = {
name: 'example',
@@ -126,6 +148,7 @@ describe('built-in slash command registry', () => {
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(
expect.arrayContaining([
+ 'add-dir',
'compact',
'btw',
'editor',
diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts
index e25a1b984..10569be53 100644
--- a/apps/kimi-code/test/tui/commands/resolve.test.ts
+++ b/apps/kimi-code/test/tui/commands/resolve.test.ts
@@ -39,6 +39,11 @@ describe('resolveSlashCommandInput', () => {
name: 'title',
args: 'New title',
});
+ expect(resolve('/add-dir list')).toMatchObject({
+ kind: 'builtin',
+ name: 'add-dir',
+ args: 'list',
+ });
expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' });
expect(resolve('/btw')).toMatchObject({
kind: 'builtin',
@@ -88,6 +93,11 @@ describe('resolveSlashCommandInput', () => {
commandName: 'reload',
reason: 'streaming',
});
+ expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({
+ kind: 'blocked',
+ commandName: 'add-dir',
+ reason: 'streaming',
+ });
expect(resolve('/experiments', { isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'experiments',
@@ -121,6 +131,11 @@ describe('resolveSlashCommandInput', () => {
commandName: 'reload',
reason: 'compacting',
});
+ expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({
+ kind: 'blocked',
+ commandName: 'add-dir',
+ reason: 'compacting',
+ });
expect(resolve('/experiments', { isCompacting: true })).toEqual({
kind: 'blocked',
commandName: 'experiments',
diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts
index ab0878d6b..4045e3a06 100644
--- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts
+++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts
@@ -34,6 +34,7 @@ function setDanceView(colored: boolean, phase: number): void {
const appState: AppState = {
version: '1.2.3',
workDir: '/tmp/project',
+ additionalDirs: [],
sessionId: 'ses-1',
sessionTitle: null,
model: 'kimi-k2',
diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts
index cc3a2ff21..a910c2481 100644
--- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts
+++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts
@@ -12,6 +12,7 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g;
const appState: AppState = {
version: '1.2.3',
workDir: '/tmp/project',
+ additionalDirs: [],
sessionId: 'ses-1',
sessionTitle: null,
model: 'kimi-k2',
diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
index fa30293cc..56cf7c5d0 100644
--- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
+++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
@@ -7,6 +7,7 @@ import type {
import { describe, expect, it, vi } from 'vitest';
import { CustomEditor } from '#/tui/components/editor/custom-editor';
+import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider';
function makeEditor(): CustomEditor {
const tui = {
@@ -73,8 +74,39 @@ describe('CustomEditor autocomplete Escape handling', () => {
});
});
+describe('CustomEditor slash argument completion refresh', () => {
+ it('reopens /add-dir directory completions after tab completion and entering slash', async () => {
+ const editor = makeEditor();
+ const provider = new FileMentionProvider(
+ [
+ {
+ name: 'add-dir',
+ description: 'Add directory',
+ getArgumentCompletions: (prefix) =>
+ prefix === '/' ? [{ value: '/tmp/shared/', label: 'shared/' }] : null,
+ },
+ ],
+ process.cwd(),
+ null,
+ );
+ editor.setAutocompleteProvider(provider);
+
+ for (const char of '/add-dir ') {
+ editor.handleInput(char);
+ }
+ await flushAutocomplete();
+
+ editor.handleInput('/');
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ await flushAutocomplete();
+
+ expect(editor.getText()).toBe('/add-dir /');
+ expect(editor.isShowingAutocomplete()).toBe(true);
+ });
+});
+
describe('CustomEditor slash menu description wrapping', () => {
- // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
+ // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences
const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, '');
it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => {
@@ -133,8 +165,8 @@ describe('CustomEditor Kitty key release handling', () => {
});
describe('CustomEditor paste marker expansion', () => {
- const PASTE_START = '\x1b[200~';
- const PASTE_END = '\x1b[201~';
+ const PASTE_START = '\u001B[200~';
+ const PASTE_END = '\u001B[201~';
function simulateLargePaste(editor: CustomEditor, content: string): void {
editor.handleInput(`${PASTE_START}${content}${PASTE_END}`);
@@ -199,7 +231,7 @@ describe('CustomEditor paste marker expansion', () => {
expect(editor.getText()).toMatch(/\[paste #1/);
- editor.handleInput('\x16');
+ editor.handleInput('\u0016');
expect(editor.getText()).not.toContain('[paste #');
expect(editor.getText()).toContain(longText);
@@ -243,7 +275,7 @@ describe('CustomEditor paste marker expansion', () => {
// Split: PASTE_START in chunk 1, paste-end split across chunk 2 and 3
editor.handleInput(`${PASTE_START}data`);
- editor.handleInput('\x1b[20');
+ editor.handleInput('\u001B[20');
editor.handleInput('1~');
expect(editor.getText()).toContain(longText);
diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts
index b898ec89c..ab0588632 100644
--- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts
+++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts
@@ -49,17 +49,43 @@ const HELP_FULL_COMMAND = {
description: 'Show help',
};
+const ADD_DIR_COMMAND = {
+ name: 'add-dir',
+ description: 'Add or list an additional workspace directory',
+ getArgumentCompletions: (prefix: string) =>
+ prefix === '/'
+ ? [
+ {
+ value: '/tmp/shared/',
+ label: 'shared/',
+ description: '/tmp/shared',
+ },
+ ]
+ : null,
+};
+
describe('FileMentionProvider', () => {
let workDir: string;
+ let extraDirs: string[];
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-'));
+ extraDirs = [];
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
+ for (const extraDir of extraDirs) {
+ rmSync(extraDir, { recursive: true, force: true });
+ }
});
+ function createExtraDir(): string {
+ const extraDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-extra-'));
+ extraDirs.push(extraDir);
+ return extraDir;
+ }
+
it('returns null when there is no completable prefix', async () => {
const provider = new FileMentionProvider([], workDir, NO_FD);
const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() });
@@ -82,6 +108,20 @@ describe('FileMentionProvider', () => {
expect(result!.items.map((item) => item.value)).toEqual(['status']);
});
+ it('opens add-dir directory completions after slash command completion and entering slash', async () => {
+ const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD);
+ const command = ADD_DIR_COMMAND;
+ const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add');
+ const completedLine = completed.lines[0]!;
+ const line = `${completedLine}/`;
+ const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() });
+
+ expect(completedLine).toBe('/add-dir ');
+ expect(result).not.toBeNull();
+ expect(result!.prefix).toBe('/');
+ expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']);
+ });
+
it('searches slash command aliases and displays aliases in the command label', async () => {
const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD);
const line = '/clear';
@@ -229,6 +269,54 @@ describe('FileMentionProvider', () => {
expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx');
});
+ it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => {
+ const extraDir = createExtraDir();
+ mkdirSync(join(extraDir, 'src'), { recursive: true });
+ writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};');
+ const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]);
+
+ const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() });
+
+ expect(result).not.toBeNull();
+ expect(result!.items.map((item) => item.value)).toContain(
+ `@${join(extraDir, 'src', 'Additional.ts')}`,
+ );
+ });
+
+ it('keeps cwd @ mention values relative and additionalDir values absolute', async () => {
+ mkdirSync(join(workDir, 'src'), { recursive: true });
+ writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};');
+ const extraDir = createExtraDir();
+ mkdirSync(join(extraDir, 'src'), { recursive: true });
+ writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};');
+ const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]);
+
+ const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() });
+ expect(cwdResult).not.toBeNull();
+ expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts');
+
+ const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() });
+ expect(additionalResult).not.toBeNull();
+ expect(additionalResult!.items.map((item) => item.value)).toContain(
+ `@${join(extraDir, 'src', 'Additional.ts')}`,
+ );
+ });
+
+ it('deduplicates cwd and additionalDir candidates by absolute path', async () => {
+ const extraDir = join(workDir, 'extra');
+ mkdirSync(join(extraDir, 'src'), { recursive: true });
+ writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};');
+ const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]);
+
+ const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() });
+
+ expect(result).not.toBeNull();
+ const overlapItems = result!.items.filter(
+ (item) => item.description === join(extraDir, 'src', 'Overlap.ts'),
+ );
+ expect(overlapItems).toHaveLength(1);
+ });
+
it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => {
writeFileSync(join(workDir, 'README.md'), 'readme');
const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'));
diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts
index a9cc544fe..62f75a8d0 100644
--- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts
+++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts
@@ -12,6 +12,7 @@ function baseState(overrides: Partial = {}): AppState {
return {
model: 'k2',
workDir: '/tmp/proj',
+ additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,
diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts
index fb1cfd5fe..f8255cddf 100644
--- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts
+++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts
@@ -22,6 +22,7 @@ function baseState(overrides: Partial = {}): AppState {
return {
model: 'k2',
workDir: '/tmp',
+ additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,
diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts
index d04c9c279..068761f7c 100644
--- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts
+++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts
@@ -13,6 +13,7 @@ function baseState(overrides: Partial = {}): AppState {
return {
model: 'k2',
workDir: '/tmp/proj',
+ additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,
diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts
index 8be57e91c..a67715f2b 100644
--- a/apps/kimi-code/test/tui/create-tui-state.test.ts
+++ b/apps/kimi-code/test/tui/create-tui-state.test.ts
@@ -8,6 +8,7 @@ function fakeInitialAppState(): AppState {
return {
model: 'test-model',
workDir: '/tmp/kimi-test',
+ additionalDirs: [],
sessionId: 'sess-1',
permissionMode: 'manual',
planMode: false,
@@ -61,6 +62,7 @@ describe('createTUIState', () => {
// App state is cloned from initialAppState, not reused by reference.
expect(state.appState).not.toBe(opts.initialAppState);
expect(state.appState.model).toBe('test-model');
+ expect(state.appState.additionalDirs).toEqual([]);
expect(state.appState.sessionId).toBe('sess-1');
expect(state.startupState).toBe('pending');
diff --git a/dev_docs/add-dir-followup-todo.md b/dev_docs/add-dir-followup-todo.md
new file mode 100644
index 000000000..2a28da5af
--- /dev/null
+++ b/dev_docs/add-dir-followup-todo.md
@@ -0,0 +1,115 @@
+# add-dir 后续 TODO
+
+本文记录 `/add-dir` 相关功能的后续待办。前置调研见 `dev_docs/add-dir-local-command-stdout-research.md`。
+
+## TODO 1:包装 ``,不进入消息流
+
+### 目标
+
+`/add-dir` 成功后,向当前 session 插入一条 user 角色的 `` 消息,但:
+
+- 不启动 turn
+- 不立刻发给 AI
+- 不出现在 live 消息流
+- 不出现在 resume 后的 transcript
+- 仍然进入模型上下文
+
+### 需要做的事
+
+- 新增 core RPC 方法,例如 `appendLocalCommandStdout`
+- 在 core agent context 中实现同名方法
+- 内容包装为:
+
+```xml
+
+...
+
+```
+
+- origin 使用内部 injection 类型,例如:
+
+```ts
+{ kind: 'injection', variant: 'local-command-stdout' }
+```
+
+- `/add-dir` 成功后调用该方法
+- 移除当前临时加入的 transcript 可见入口
+- `--add-dir` / resume flag 不插入该消息
+
+### 验收标准
+
+- `/add-dir` 成功后写入一条 user role record
+- record 内容为 `...`
+- origin 为内部 injection 类型
+- TUI transcript 不显示该消息
+- resume 后 transcript 不显示该消息
+- 下一轮用户发消息时,该记录会作为上下文发送给模型
+
+## TODO 2:slash 文件补全优化,支持连续 Tab 显示补全列表
+
+### 目标
+
+优化 slash command 中的文件/路径补全体验,特别是 `/add-dir ` 这类场景。
+
+当前问题:
+
+- 文件补全不支持连续 Tab 显示补全列表
+- 用户需要记住路径或手动输入完整路径
+- 补全交互不如普通 shell 顺手
+
+### 需要做的事
+
+- 梳理当前 slash command 的文件补全入口
+- 确认补全逻辑是复用 file mention provider,还是独立实现
+- 支持连续 Tab 触发补全列表展示
+- 支持目录/文件候选展示
+- 支持选择后回填到输入框
+- 处理目录结尾 `/` 与继续补全子目录
+
+### 验收标准
+
+- 输入 `/add-dir ` 后按 Tab 可以显示候选列表
+- 连续按 Tab 可以浏览/切换候选
+- 选择目录后可以继续补全子目录
+- 选择文件/目录后正确回填
+- 不影响普通 `@` 文件提及补全
+
+## TODO 3:支持显示 hint
+
+### 目标
+
+slash command / 补全列表中支持显示 hint,帮助用户理解每个选项的含义。
+
+当前问题:
+
+- 补全项缺少说明
+- 用户无法从列表中判断选项作用
+- 对 `/add-dir` 这种涉及 session-only / remember 的交互尤其需要 hint
+
+### 需要做的事
+
+- 梳理当前补全/选择组件的数据结构
+- 为候选项增加 hint 字段
+- UI 支持展示 hint
+- 对 `/add-dir` 的选项补充明确 hint,例如:
+ - `Yes, for this session` → session-only,不写入 local config
+ - `Yes, and remember this directory` → 写入 `.kimi-code/local.toml`
+ - `No` → 取消添加
+
+### 验收标准
+
+- slash command 选项可以展示 hint
+- 补全列表可以展示 hint
+- hint 文案清晰,不超出 UI 宽度
+- 不影响现有选择/确认交互
+
+## 优先级建议
+
+1. TODO 1:`` 包装与隐藏
+ 这是当前 `/add-dir` 行为收尾,依赖调研结论,优先级最高。
+
+2. TODO 3:hint 显示
+ 与 `/add-dir` 选择项体验直接相关,可以较早做。
+
+3. TODO 2:连续 Tab 文件补全
+ 依赖补全组件梳理,改动可能更大,可以放在 TODO 1 / TODO 3 之后。
diff --git a/dev_docs/add-dir-local-command-stdout-research.md b/dev_docs/add-dir-local-command-stdout-research.md
new file mode 100644
index 000000000..f6f15a1db
--- /dev/null
+++ b/dev_docs/add-dir-local-command-stdout-research.md
@@ -0,0 +1,280 @@
+# add-dir / local-command-stdout 调研记录
+
+本文记录 `/add-dir`、`--add-dir`、``、bash mode 输出在参考实现中的行为,以及它们对 Kimi Code 当前实现的影响。
+
+## 1. 背景
+
+Kimi Code 当前正在支持 workspace additional dirs:
+
+- `--add-dir `:启动/resume 时添加额外 workspace 目录
+- `/add-dir `:在 TUI 中手动添加额外 workspace 目录
+
+当前已经修复/确认的行为:
+
+- `--add-dir` 在 resume / continue 场景下也会传入并合并到 session additionalDirs
+- caller 传入的相对路径按 `workDir` 解析,而不是 projectRoot
+- additional dirs 默认 session-only,不写入 `.kimi-code/local.toml`
+- additional dirs 的 `AGENTS.md` 默认不加载进上下文,只保留目录 listing
+
+接下来要确定的是:`/add-dir` 成功后应该插入什么形式的消息。
+
+## 2. 参考实现里的 `/add-dir` 行为
+
+参考实现中,`/add-dir` 是 `local-jsx` 类型的 slash command。
+
+执行成功后,它不会插入 `system-reminder`,而是通过 local slash command 的输出机制插入一条 user 角色消息,内容用 `` 包裹。
+
+示例:
+
+```xml
+
+Added /path/to/dir as a working directory for this session · /permissions to manage
+
+```
+
+如果是 remember 模式,内容类似:
+
+```xml
+
+Added /path/to/dir as a working directory and saved to local settings · /permissions to manage
+
+```
+
+关键点:
+
+- 不是 `system-reminder`
+- 不是 assistant 消息
+- 是 user 角色的命令输出消息
+- 不会立刻触发模型回复,等价于 `shouldQuery: false`
+- 会进入历史,下一轮用户发消息时一起发给模型
+
+## 3. 参考实现里的 `--add-dir` / resume 行为
+
+参考实现中,`--add-dir` 在启动或 resume 时:
+
+- 会初始化额外目录状态
+- 会把目录加入权限上下文
+- 会影响 system prompt 里的额外目录展示
+- 但**不会**插入 ``
+- 也**不会**插入 `system-reminder`
+
+也就是说,参考实现只在**手动 `/add-dir`** 时插入命令输出消息;`--add-dir` 只是状态初始化。
+
+另外,参考实现的 resume 不会恢复会话中通过 `/add-dir` 临时加入的 session-only 目录;只有 resume 命令行再次带 `--add-dir` 时才会重新应用。
+
+Kimi Code 当前已经实现了 resume 时传入 `--add-dir`,这一点比参考实现更完整。
+
+## 4. `` 的用途
+
+`` 是“本地 slash command 输出”的通用包装。
+
+典型场景包括:
+
+1. 本地配置/状态命令
+ - `/add-dir`
+ - `/config`
+ - `/status`
+ - `/doctor`
+
+2. 本地信息命令
+ - `/cost`
+ - `/voice`
+ - `/usage`
+
+3. 本地会话操作命令
+ - `/compact`
+ - `/clear`
+ - `/btw`
+
+4. fork 子代理命令结果
+
+5. bridge / remote 不支持的命令拒绝消息
+
+错误输出对应另一个 tag:
+
+```xml
+
+```
+
+## 5. `` 的语义
+
+它在参考实现里有几个重要语义:
+
+- 表示命令输出,不是用户自然语言输入
+- 默认会进入模型上下文
+- 不会立刻触发模型回复
+- UI 会识别并做专门渲染
+- title / rewind / resubmit / prompt 计数等逻辑会把它从“真实用户输入”里排除
+- 但它仍然作为历史发给模型
+
+需要注意:这些功能的“排除”不是从模型上下文删除,而是不把它当作“用户主动输入的一句话”。
+
+## 6. bash mode 的对比
+
+参考实现里 bash mode(例如 `!ls`)不使用 ``,而是使用专门的 bash tag。
+
+bash 输入:
+
+```xml
+ls
+```
+
+bash 输出:
+
+```xml
+...
+...
+```
+
+同时还有一条 caveat:
+
+```xml
+
+...
+
+```
+
+bash 行为:
+
+- 输入和输出都是 user 角色消息
+- caveat 是 user 消息,但带 meta 标记
+- `shouldQuery: false`
+- 执行完只 append 到上下文,本轮不请求模型
+- 下一轮用户正常输入时,这些 bash 消息会作为历史一起发给模型
+
+结论:
+
+| 场景 | tag |
+|---|---|
+| slash command 输出 | `` |
+| slash command 错误 | `` |
+| bash 输入 | `` |
+| bash 输出 | `` |
+| bash 错误 | `` |
+
+Kimi Code 当前要处理的是 slash command 输出,所以应该用 ``,而不是 bash tag。
+
+## 7. `` 是否进入模型上下文
+
+默认会。
+
+参考实现的 API 规范化逻辑会把 local command 输出纳入 API 对话,并与相邻 user 消息合并。
+
+原因是:模型需要能在后续轮次引用之前的命令输出。
+
+但它不是立刻发送:
+
+```text
+执行 slash command
+↓
+插入
+↓
+本轮不主动请求模型
+↓
+用户下一条正常消息
+↓
+命令输出作为历史一起发送
+```
+
+## 8. title / rewind / resubmit / prompt 计数的影响
+
+这些功能会跳过 ``,但只是从“真实用户输入”逻辑里跳过,不是从模型上下文删除。
+
+### title
+
+生成会话标题时,不会用命令输出当标题。
+
+例如不会用:
+
+```text
+Added ../shared as a working directory
+```
+
+作为会话标题。
+
+但消息仍在历史里。
+
+### rewind
+
+rewind 选择器会跳过命令输出,因为它不是用户主动输入的 prompt。
+
+但如果用户 rewind 到更早的时间点,这之后的命令输出也会随历史一起被移除。
+
+### resubmit
+
+resubmit 不会把命令输出当作用户输入重新提交。
+
+但如果 resubmit 某个真实 prompt,历史里已有的命令输出仍然会作为上下文一起发送。
+
+### prompt 计数
+
+统计用户 prompt 数量时,会跳过 terminal output。
+
+也就是说:
+
+```text
+普通用户输入:算一个 prompt
+:不算
+```
+
+但这只是计数,不影响上下文。
+
+## 9. 对 Kimi Code 的设计建议
+
+当前需求是:
+
+- `/add-dir` 成功后插入一条 ``
+- 不插入 `system-reminder`
+- 不立刻发给 AI
+- 不出现在 live 消息流
+- 不出现在 resume 后的用户可见 transcript
+- 但仍然进入模型上下文
+
+这个需求和参考实现不完全一致。参考实现里 `` 通常会在 transcript / resume 里出现;而当前需求要求它对用户不可见。
+
+因此建议设计:
+
+1. 新增一个 core RPC 方法,例如 `appendLocalCommandStdout`
+2. 在 core agent context 里实现同名方法
+3. 内容包成:
+
+```xml
+
+...
+
+```
+
+4. role 用 `user`
+5. origin 用内部 injection 类型,例如:
+
+```ts
+{ kind: 'injection', variant: 'local-command-stdout' }
+```
+
+6. 不启动 turn
+7. 不 append 到 TUI transcript
+8. `--add-dir` / resume flag 不插入这条消息,只有手动 `/add-dir` 插入
+
+这样做的效果:
+
+- 用户在 live transcript 里看不到它
+- resume 后 transcript 也看不到
+- 不会作为真实用户输入参与 undo / title / resubmit / prompt 计数
+- 但仍在历史 records 里
+- 下一轮用户发消息时,会作为上下文发给模型
+
+## 10. 待确认点
+
+实现前需要确认:
+
+1. `variant` 名称用 `local-command-stdout` 还是 `add-dir`
+2. `/add-dir` 成功后是否保留 `showStatus` 的瞬时反馈
+3. 这条消息是否需要写入 records(建议写入,否则 resume 后模型也看不到)
+4. 是否需要覆盖 export / replay 的隐藏行为测试
+
+当前推荐:
+
+- `variant: 'local-command-stdout'`
+- 保留 `showStatus` 作为瞬时反馈,但不写入 transcript
+- 写入 records,确保模型后续可见
+- 增加 runtime 测试,确认 record 是 user 角色、内容为 ``、origin 为内部 injection
diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts
index 392c1d4ea..cfb913bf5 100644
--- a/packages/agent-core/src/agent/index.ts
+++ b/packages/agent-core/src/agent/index.ts
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { join } from 'pathe';
+import { normalizeAdditionalDirs } from '../config';
import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors';
import { log } from '#/logging/logger';
import type { Logger } from '#/logging/types';
@@ -89,6 +90,7 @@ export interface AgentOptions {
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
readonly experimentalFlags?: ExperimentalFlagResolver;
readonly replay?: ReplayBuilderOptions;
+ readonly additionalDirs?: readonly string[];
}
export class Agent {
@@ -132,6 +134,7 @@ export class Agent {
readonly goal: GoalMode;
readonly replayBuilder: ReplayBuilder;
+ private additionalDirs: readonly string[];
private lastLlmConfigLogSignature?: string;
constructor(options: AgentOptions) {
@@ -150,6 +153,7 @@ export class Agent {
this.log = options.log ?? log;
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
+ this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
this.blobStore = options.homedir
? new BlobStore({ blobsDir: join(options.homedir, 'blobs') })
@@ -191,6 +195,17 @@ export class Agent {
this._kaos = kaos;
}
+ getAdditionalDirs(): readonly string[] {
+ return this.additionalDirs;
+ }
+
+ setAdditionalDirs(additionalDirs: readonly string[]): void {
+ this.additionalDirs = normalizeAdditionalDirs(additionalDirs);
+ if (this.config.hasProvider) {
+ this.tools.initializeBuiltinTools();
+ }
+ }
+
get generate(): typeof generate {
return async (provider, systemPrompt, tools, history, callbacks, options) => {
if (options?.auth !== undefined) {
@@ -288,6 +303,7 @@ export class Agent {
skills: this.skills?.registry,
cwdListing: context?.cwdListing,
agentsMd: context?.agentsMd,
+ additionalDirsInfo: context?.additionalDirsInfo,
});
this.config.update({ profileName: profile.name, systemPrompt });
this.tools.setActiveTools(profile.tools);
@@ -318,6 +334,9 @@ export class Agent {
this.telemetry.track('input_steer', { parts: payload.input.length });
this.turn.steer(payload.input);
},
+ appendUserMessage: (payload) => {
+ this.context.appendUserMessage(payload.input);
+ },
cancel: (payload) => {
if (this.turn.hasActiveTurn) {
this.telemetry.track('cancel', { from: 'streaming' });
diff --git a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts
index 14c72f351..8cb6fffee 100644
--- a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts
+++ b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts
@@ -1,5 +1,5 @@
import type { Agent } from '../..';
-import { isWithinDirectory } from '../../../tools/policies/path-access';
+import { isWithinWorkspace } from '../../../tools/policies/path-access';
import { findGitWorkTreeMarker } from '../../../tools/support/git-worktree';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
import { writeFileAccesses } from './file-access-ask';
@@ -19,7 +19,15 @@ export class GitCwdWriteApprovePermissionPolicy implements PermissionPolicy {
const writeAccesses = writeFileAccesses(context);
if (writeAccesses.length === 0) return;
- if (!writeAccesses.every((access) => isWithinDirectory(access.path, cwd, 'posix'))) {
+ if (
+ !writeAccesses.every((access) =>
+ isWithinWorkspace(
+ access.path,
+ { workspaceDir: cwd, additionalDirs: this.agent.getAdditionalDirs() },
+ 'posix',
+ ),
+ )
+ ) {
return;
}
diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts
index b80a87f31..91a8c9012 100644
--- a/packages/agent-core/src/agent/tool/index.ts
+++ b/packages/agent-core/src/agent/tool/index.ts
@@ -367,7 +367,7 @@ export class ToolManager {
const workspace = extendWorkspaceWithSkillRoots(
{
workspaceDir: cwd,
- additionalDirs: [],
+ additionalDirs: this.agent.getAdditionalDirs(),
},
this.agent.skills?.registry.getSkillRoots() ?? [],
);
diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts
index 41e5ca174..ac97d1221 100644
--- a/packages/agent-core/src/config/index.ts
+++ b/packages/agent-core/src/config/index.ts
@@ -4,3 +4,4 @@ export * from './resolve';
export * from './schema';
export * from './toml';
export * from './env-model';
+export * from './workspace-local';
diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts
new file mode 100644
index 000000000..499529c9d
--- /dev/null
+++ b/packages/agent-core/src/config/workspace-local.ts
@@ -0,0 +1,313 @@
+import { mkdir, readFile } from 'node:fs/promises';
+
+import type { Kaos } from '@moonshot-ai/kaos';
+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 { writeFileAtomicDurable } from '#/utils/fs';
+
+const S_IFMT = 0o170000;
+const S_IFDIR = 0o040000;
+
+const WorkspaceLocalTomlSchema = z.object({
+ workspace: z
+ .object({
+ additional_dir: z.array(z.string()),
+ })
+ .optional(),
+});
+
+type WorkspaceLocalToml = z.infer;
+
+export interface WorkspaceAdditionalDirsLoadResult {
+ readonly projectRoot: string;
+ readonly configPath: string;
+ readonly additionalDirs: readonly string[];
+ readonly warning?: string;
+}
+
+export type WorkspaceLocalConfig = WorkspaceAdditionalDirsLoadResult;
+
+interface WorkspaceLocalTomlFile {
+ readonly raw: Record;
+ readonly parsed: WorkspaceLocalToml;
+}
+
+export async function loadWorkspaceLocalConfig(
+ kaos: Kaos,
+ workDir: string,
+): Promise {
+ const projectRoot = await findProjectRoot(kaos, workDir);
+ const configPath = getWorkspaceLocalConfigPath(projectRoot);
+ const file = await readWorkspaceLocalToml(configPath);
+
+ const additionalDirs = file?.parsed.workspace?.additional_dir;
+ if (additionalDirs === undefined) {
+ return { projectRoot, configPath, additionalDirs: [] };
+ }
+
+ return {
+ projectRoot,
+ configPath,
+ additionalDirs: await resolveAdditionalDirs(kaos, projectRoot, additionalDirs),
+ };
+}
+
+export async function readWorkspaceAdditionalDirs(
+ kaos: Kaos,
+ workDir: string,
+): Promise {
+ return loadWorkspaceLocalConfig(kaos, workDir);
+}
+
+export async function resolveWorkspaceAdditionalDirs(
+ kaos: Kaos,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): Promise {
+ return resolveAdditionalDirs(kaos, projectRoot, additionalDirs);
+}
+
+export async function appendWorkspaceAdditionalDir(
+ kaos: Kaos,
+ workDir: string,
+ inputPath: string,
+ _currentAdditionalDirs: readonly string[],
+): Promise {
+ const projectRoot = await findProjectRoot(kaos, workDir);
+ const configPath = getWorkspaceLocalConfigPath(projectRoot);
+ const additionalDir = await resolveAdditionalDir(kaos, projectRoot, inputPath);
+ const file = (await readWorkspaceLocalToml(configPath)) ?? { raw: {}, parsed: {} };
+ const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? [];
+ const fileExistingDirs = resolveExistingAdditionalDirs(kaos, projectRoot, fileAdditionalDirs);
+
+ if (hasSameAdditionalDir(kaos, fileExistingDirs, additionalDir)) {
+ return { projectRoot, configPath, additionalDirs: fileExistingDirs };
+ }
+
+ const workspace = cloneRecord(file.raw['workspace']);
+ workspace['additional_dir'] = [...fileExistingDirs, additionalDir];
+ file.raw['workspace'] = workspace;
+
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
+ await writeFileAtomicDurable(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(kaos: Kaos, workDir: string): Promise {
+ const initial = resolveWorkDir(kaos, workDir);
+ let current = initial;
+
+ for (;;) {
+ if (await pathExists(kaos, join(current, '.git'))) return current;
+ const parent = dirname(current);
+ if (parent === current) return initial;
+ current = parent;
+ }
+}
+
+function resolveWorkDir(kaos: Kaos, workDir: string): string {
+ return isAbsolute(workDir) ? kaos.normpath(workDir) : resolve(kaos.getcwd(), workDir);
+}
+
+async function readWorkspaceLocalToml(configPath: string): Promise {
+ let text: string;
+ try {
+ text = await readFile(configPath, 'utf-8');
+ } 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(
+ kaos: Kaos,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): Promise {
+ const resolvedDirs: string[] = [];
+
+ for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
+ const resolvedDir = await resolveAdditionalDir(kaos, projectRoot, additionalDir);
+ if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue;
+ resolvedDirs.push(resolvedDir);
+ }
+
+ return resolvedDirs;
+}
+
+function resolveExistingAdditionalDirs(
+ kaos: Kaos,
+ projectRoot: string,
+ additionalDirs: readonly string[],
+): string[] {
+ const resolvedDirs: string[] = [];
+
+ for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
+ const resolvedDir = resolvePath(projectRoot, additionalDir);
+ if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue;
+ resolvedDirs.push(resolvedDir);
+ }
+
+ return resolvedDirs;
+}
+
+async function resolveAdditionalDir(
+ kaos: Kaos,
+ projectRoot: string,
+ additionalDir: string,
+): Promise {
+ const normalizedInput = normalizeAdditionalDirInput(additionalDir);
+ const resolvedDir = resolvePath(projectRoot, normalizedInput);
+ await assertDirectory(kaos, 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(projectRoot: string, additionalDir: string): string {
+ return isAbsolute(additionalDir) ? normalize(additionalDir) : resolve(projectRoot, additionalDir);
+}
+
+function hasSameAdditionalDir(kaos: Kaos, dirs: readonly string[], target: string): boolean {
+ const normalizedTarget = normalizeForCompare(kaos, target);
+ return dirs.some((dir) => normalizeForCompare(kaos, dir) === normalizedTarget);
+}
+
+function normalizeForCompare(kaos: Kaos, filePath: string): string {
+ return kaos.normpath(filePath);
+}
+
+async function assertDirectory(kaos: Kaos, filePath: string): Promise {
+ try {
+ const stat = await kaos.stat(filePath);
+ if ((stat.stMode & S_IFMT) === S_IFDIR) return;
+ } 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 },
+ );
+ }
+
+ throw new KimiError(
+ ErrorCodes.CONFIG_INVALID,
+ 'workspace.additional_dir must exist and be a directory',
+ );
+}
+
+async function pathExists(kaos: Kaos, filePath: string): Promise {
+ try {
+ await kaos.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/src/profile/context.ts b/packages/agent-core/src/profile/context.ts
index 49d8d8105..2e56f2aba 100644
--- a/packages/agent-core/src/profile/context.ts
+++ b/packages/agent-core/src/profile/context.ts
@@ -2,6 +2,7 @@ import { dirname, join } from 'pathe';
import type { Kaos } from '@moonshot-ai/kaos';
+import { normalizeAdditionalDirs } from '../config';
import { listDirectory } from '../tools/support/list-directory';
import type { SystemPromptContext } from './types';
@@ -11,23 +12,38 @@ const AGENTS_MD_TRUNCATION_MARKER =
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
-export type PreparedSystemPromptContext = Pick;
+export type PreparedSystemPromptContext = Pick<
+ SystemPromptContext,
+ 'cwdListing' | 'agentsMd' | 'additionalDirsInfo'
+>;
+
+export interface PrepareSystemPromptContextOptions {
+ readonly additionalDirs?: readonly string[];
+}
export async function prepareSystemPromptContext(
kaos: Kaos,
brandHome?: string,
+ options?: PrepareSystemPromptContextOptions,
): Promise {
- const [cwdListing, agentsMd] = await Promise.all([
+ const additionalDirs = normalizeAdditionalDirs(options?.additionalDirs ?? []);
+ const [cwdListing, agentsMd, additionalDirsInfo] = await Promise.all([
listDirectory(kaos, undefined, { collapseHiddenDirs: true }),
loadAgentsMd(kaos, brandHome),
+ loadAdditionalDirsInfo(kaos, additionalDirs),
]);
- return { cwdListing, agentsMd };
+ return { cwdListing, agentsMd, additionalDirsInfo };
}
export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise {
- const workDir = kaos.getcwd();
- const projectRoot = await findProjectRoot(kaos, workDir);
- const dirs = dirsRootToLeaf(kaos, workDir, projectRoot);
+ return loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
+}
+
+async function loadAgentsMdForRoots(
+ kaos: Kaos,
+ brandHome: string | undefined,
+ workDirs: readonly string[],
+): Promise {
const discovered: AgentFile[] = [];
const seen = new Set();
@@ -57,16 +73,37 @@ export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise {
+ const sections = await Promise.all(
+ additionalDirs.map(async (dir) => {
+ const listing = await listDirectory(kaos.withCwd(dir));
+ return `### ${dir}\n${listing}`;
+ }),
+ );
+
+ return sections.join('\n\n');
+}
+
async function findProjectRoot(kaos: Kaos, workDir: string): Promise {
const initial = kaos.normpath(workDir);
let current = initial;
@@ -163,13 +200,34 @@ function renderAgentFiles(files: readonly AgentFile[]): string {
}
function truncateUtf8(text: string, maxBytes: number): string {
- let result = text;
- while (byteLength(result) > maxBytes) {
+ if (maxBytes <= 0) return '';
+ if (byteLength(text) <= maxBytes) return text;
+
+ let low = 0;
+ let high = text.length;
+ while (low < high) {
+ const mid = Math.ceil((low + high) / 2);
+ const candidate = text.slice(0, mid);
+ if (byteLength(candidate) <= maxBytes) {
+ low = mid;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ let result = text.slice(0, low);
+ while (endsWithUnpairedHighSurrogate(result)) {
result = result.slice(0, -1);
}
return result;
}
+function endsWithUnpairedHighSurrogate(text: string): boolean {
+ if (text.length === 0) return false;
+ const codePoint = text.codePointAt(text.length - 1);
+ return codePoint !== undefined && codePoint >= 0xd800 && codePoint <= 0xdbff;
+}
+
function byteLength(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts
index c3ba9f6a5..3d61257f0 100644
--- a/packages/agent-core/src/rpc/core-api.ts
+++ b/packages/agent-core/src/rpc/core-api.ts
@@ -48,6 +48,7 @@ export interface CreateSessionPayload {
readonly permission?: PermissionMode | undefined;
readonly metadata?: JsonObject | undefined;
readonly mcpServers?: Readonly>;
+ readonly additionalDirs?: readonly string[];
}
export interface CloseSessionPayload {
@@ -57,6 +58,7 @@ export interface CloseSessionPayload {
export interface ResumeSessionPayload {
readonly sessionId: string;
readonly mcpServers?: Readonly>;
+ readonly additionalDirs?: readonly string[];
}
export interface ReloadSessionPayload {
@@ -140,6 +142,7 @@ export interface SessionSummary {
readonly updatedAt: number;
readonly archived?: boolean | undefined;
readonly metadata?: JsonObject | undefined;
+ readonly additionalDirs?: readonly string[];
}
export interface PromptPayload {
@@ -148,6 +151,9 @@ export interface PromptPayload {
export interface SteerPayload {
readonly input: readonly ContentPart[];
}
+export interface AppendUserMessagePayload {
+ readonly input: readonly ContentPart[];
+}
export interface CancelPayload {
readonly turnId?: number;
}
@@ -263,6 +269,18 @@ export interface GetPluginInfoPayload {
export type ReloadPluginsResult = ReloadSummary;
export type { PluginSummary, PluginInfo };
+export interface AddAdditionalDirPayload {
+ readonly path: string;
+ readonly persist: boolean;
+}
+
+export interface AddAdditionalDirResult {
+ readonly additionalDirs: readonly string[];
+ readonly projectRoot: string;
+ readonly configPath: string;
+ readonly persisted: boolean;
+}
+
export interface RenameSessionPayload {
readonly title: string;
}
@@ -308,6 +326,7 @@ export interface RemoveKimiProviderPayload {
export interface AgentAPI {
prompt: (payload: PromptPayload) => void;
steer: (payload: SteerPayload) => void;
+ appendUserMessage: (payload: AppendUserMessagePayload) => void;
cancel: (payload: CancelPayload) => void;
undoHistory: (payload: UndoHistoryPayload) => void;
setThinking: (payload: SetThinkingPayload) => void;
@@ -355,6 +374,7 @@ export interface SessionAPI extends AgentAPIWithId {
getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics;
reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void;
generateAgentsMd: (payload: EmptyPayload) => void;
+ addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult;
}
type SessionAPIWithId = WithSessionId;
diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts
index 204715da6..a1ff49eb7 100644
--- a/packages/agent-core/src/rpc/core-impl.ts
+++ b/packages/agent-core/src/rpc/core-impl.ts
@@ -16,6 +16,9 @@ import {
loadRuntimeConfigSafe,
mergeConfigPatch,
readConfigFileForUpdate,
+ normalizeAdditionalDirs,
+ readWorkspaceAdditionalDirs,
+ resolveWorkspaceAdditionalDirs,
resolveConfigPath,
resolveKimiHome,
writeConfigFile,
@@ -42,6 +45,9 @@ import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from
import type { CoreRPCClient } from './client';
import type {
ActivateSkillPayload,
+ AddAdditionalDirPayload,
+ AddAdditionalDirResult,
+ AppendUserMessagePayload,
BeginCompactionPayload,
CancelPayload,
CancelPlanPayload,
@@ -212,6 +218,18 @@ export class KimiCore implements PromisableMethods {
homeDir: this.homeDir,
});
const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, options.mcpServers);
+ const parentKaos = overrides.kaos ?? (await this.getKaos());
+ const persistenceKaos = overrides.persistenceKaos ?? parentKaos;
+ const localWorkspaceDirs = await readWorkspaceAdditionalDirs(parentKaos, workDir);
+ const callerAdditionalDirs = await resolveWorkspaceAdditionalDirs(
+ parentKaos,
+ workDir,
+ options.additionalDirs ?? [],
+ );
+ const additionalDirs = normalizeAdditionalDirs([
+ ...localWorkspaceDirs.additionalDirs,
+ ...callerAdditionalDirs,
+ ]);
const summary = await this.sessionStore.create({
id,
workDir,
@@ -228,8 +246,6 @@ export class KimiCore implements PromisableMethods {
// Session ctor attaches its own log sink. If anything in the setup-after-
// ctor block throws, `session.close()` releases the sink (and mcp).
const runtime = await this.resolveRuntime(config);
- const parentKaos = overrides.kaos ?? (await this.getKaos());
- const persistenceKaos = overrides.persistenceKaos ?? parentKaos;
const session = new Session({
kaos: parentKaos.withCwd(workDir),
persistenceKaos,
@@ -249,6 +265,7 @@ export class KimiCore implements PromisableMethods {
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
pluginSessionStarts,
appVersion: this.appVersion,
+ additionalDirs,
});
try {
session.metadata = {
@@ -283,7 +300,7 @@ export class KimiCore implements PromisableMethods {
throw error;
}
this.sessions.set(id, session);
- return result;
+ return withAdditionalDirs(result, session);
}
getCoreInfo(): CoreInfo {
@@ -311,12 +328,24 @@ export class KimiCore implements PromisableMethods {
overrides: { kaos?: Kaos; persistenceKaos?: Kaos },
): Promise {
const summary = await this.sessionStore.get(input.sessionId);
+ const parentKaosForRead = overrides.kaos ?? (await this.getKaos());
+ const localWorkspaceDirs = await readWorkspaceAdditionalDirs(parentKaosForRead, summary.workDir);
+ const callerAdditionalDirs = await resolveWorkspaceAdditionalDirs(
+ parentKaosForRead,
+ summary.workDir,
+ input.additionalDirs ?? [],
+ );
+ const additionalDirs = normalizeAdditionalDirs([
+ ...localWorkspaceDirs.additionalDirs,
+ ...callerAdditionalDirs,
+ ]);
const active = this.sessions.get(summary.id);
if (active !== undefined) {
if (overrides.kaos !== undefined) {
active.setToolKaos(overrides.kaos.withCwd(summary.workDir));
}
- return resumeSessionResult(summary, active);
+ await active.setAdditionalDirs(additionalDirs);
+ return withAdditionalDirs(await resumeSessionResult(summary, active), active);
}
const config = this.reloadProviderManager();
@@ -329,7 +358,7 @@ export class KimiCore implements PromisableMethods {
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(withCallerMcp);
const runtime = await this.resolveRuntime(config);
- const parentKaos = overrides.kaos ?? (await this.getKaos());
+ const parentKaos = parentKaosForRead;
const persistenceKaos = overrides.persistenceKaos ?? parentKaos;
const session = new Session({
kaos: parentKaos.withCwd(summary.workDir),
@@ -351,6 +380,7 @@ export class KimiCore implements PromisableMethods {
initializeMainAgent: false,
pluginSessionStarts,
appVersion: this.appVersion,
+ additionalDirs,
});
let warning: string | undefined;
try {
@@ -515,6 +545,10 @@ export class KimiCore implements PromisableMethods {
return this.sessionApi(sessionId).steer(payload);
}
+ appendUserMessage({ sessionId, ...payload }: SessionAgentPayload) {
+ return this.sessionApi(sessionId).appendUserMessage(payload);
+ }
+
cancel({ sessionId, ...payload }: SessionAgentPayload) {
return this.sessionApi(sessionId).cancel(payload);
}
@@ -674,6 +708,13 @@ export class KimiCore implements PromisableMethods {
return this.sessionApi(sessionId).generateAgentsMd(payload);
}
+ addAdditionalDir({
+ sessionId,
+ ...payload
+ }: SessionScopedPayload): Promise {
+ return this.requireSession(sessionId).addAdditionalDir(payload.path, payload.persist);
+ }
+
startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise {
return this.sessionApi(sessionId).startBtw(payload);
}
@@ -867,14 +908,18 @@ export class KimiCore implements PromisableMethods {
return env;
}
- private sessionApi(sessionId: string): SessionAPIImpl {
+ private requireSession(sessionId: string): Session {
const session = this.sessions.get(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, {
details: { sessionId },
});
}
- return new SessionAPIImpl(session);
+ return session;
+ }
+
+ private sessionApi(sessionId: string): SessionAPIImpl {
+ return new SessionAPIImpl(this.requireSession(sessionId));
}
private reloadProviderManager(): KimiConfig {
@@ -1017,6 +1062,16 @@ function createSessionId(): string {
return `session_${randomUUID()}`;
}
+function withAdditionalDirs(
+ result: T,
+ session: Session,
+): T & { readonly additionalDirs: readonly string[] } {
+ return {
+ ...result,
+ additionalDirs: session.getAdditionalDirs(),
+ };
+}
+
function telemetryErrorReason(error: unknown): string {
if (error instanceof KimiError) return error.code;
if (error instanceof Error && error.name.length > 0) return error.name;
@@ -1053,12 +1108,15 @@ async function resumeSessionResult(
background: agent.background.list(false),
};
}
- return {
- ...summary,
- sessionMetadata: api.getSessionMetadata({}),
- agents,
- warning,
- };
+ return withAdditionalDirs(
+ {
+ ...summary,
+ sessionMetadata: api.getSessionMetadata({}),
+ agents,
+ warning,
+ },
+ session,
+ );
}
async function warnIfLogFlushFails(
diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts
index ca8c531a9..945990b79 100644
--- a/packages/agent-core/src/session/index.ts
+++ b/packages/agent-core/src/session/index.ts
@@ -11,7 +11,16 @@ import { proxyWithExtraPayload } from '#/rpc/types';
import { Agent, type AgentOptions, type AgentType } from '../agent';
import { HookEngine, type HookDef } from './hooks';
import type { PermissionManagerOptions, PermissionRule } from '../agent/permission';
-import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config';
+import {
+ appendWorkspaceAdditionalDir,
+ normalizeAdditionalDirs,
+ parseBooleanEnv,
+ readWorkspaceAdditionalDirs,
+ resolveWorkspaceAdditionalDirs,
+ resolveConfigValue,
+ type BackgroundConfig,
+ type WorkspaceAdditionalDirsLoadResult,
+} from '../config';
import { makeErrorPayload } from '../errors';
import {
McpConnectionManager,
@@ -62,6 +71,7 @@ export interface SessionOptions {
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
readonly appVersion?: string;
readonly experimentalFlags?: ExperimentalFlagResolver;
+ readonly additionalDirs?: readonly string[];
}
export interface SessionSkillConfig {
@@ -147,6 +157,7 @@ export class Session {
readonly experimentalFlags: ExperimentalFlagResolver;
private toolKaos: Kaos;
private persistenceKaos: Kaos;
+ private additionalDirs: readonly string[];
private agentIdCounter = 0;
private readonly skillsReady: Promise;
metadata: SessionMeta = {
@@ -182,6 +193,7 @@ export class Session {
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.toolKaos = options.kaos;
this.persistenceKaos = options.persistenceKaos ?? options.kaos;
+ this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
this.skills = new SessionSkillRegistry({
sessionId: options.id,
});
@@ -213,6 +225,42 @@ export class Session {
this.refreshAgentBuiltinTools();
}
+ getAdditionalDirs(): readonly string[] {
+ return this.additionalDirs;
+ }
+
+ async setAdditionalDirs(additionalDirs: readonly string[]): Promise {
+ this.additionalDirs = normalizeAdditionalDirs(additionalDirs);
+ for (const agent of this.readyAgents()) {
+ agent.setAdditionalDirs(this.additionalDirs);
+ }
+ }
+
+ async addAdditionalDir(
+ path: string,
+ persist = true,
+ ): Promise {
+ const cwd = this.toolKaos.getcwd();
+ const systemKaos = this.systemContextKaos(cwd);
+ if (persist) {
+ const result = await appendWorkspaceAdditionalDir(systemKaos, cwd, path, this.additionalDirs);
+ const additionalDirs = normalizeAdditionalDirs([...this.additionalDirs, ...result.additionalDirs]);
+ await this.setAdditionalDirs(additionalDirs);
+ return { ...result, additionalDirs, persisted: true };
+ }
+
+ const workspace = await readWorkspaceAdditionalDirs(systemKaos, cwd);
+ const additionalDirs = await resolveWorkspaceAdditionalDirs(systemKaos, workspace.projectRoot, [path]);
+ const nextAdditionalDirs = normalizeAdditionalDirs([...this.additionalDirs, ...additionalDirs]);
+ await this.setAdditionalDirs(nextAdditionalDirs);
+ return {
+ projectRoot: workspace.projectRoot,
+ configPath: workspace.configPath,
+ additionalDirs: nextAdditionalDirs,
+ persisted: false,
+ };
+ }
+
/**
* Kaos used by session-internal bootstrap (AGENTS.md context, cwd listing)
* and metadata persistence. Always backed by the persistence sink (typically
@@ -407,6 +455,7 @@ export class Session {
const context = await prepareSystemPromptContext(
this.systemContextKaos(agent.kaos.getcwd()),
this.options.kimiHomeDir,
+ { additionalDirs: this.additionalDirs },
);
agent.useProfile(profile, context);
}
@@ -581,6 +630,7 @@ export class Session {
log: this.log.createChild({ agentId: id }),
pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined,
experimentalFlags: this.experimentalFlags,
+ additionalDirs: parentAgent?.getAdditionalDirs() ?? this.additionalDirs,
});
}
diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts
index fe81014dc..c556fff3d 100644
--- a/packages/agent-core/src/session/rpc.ts
+++ b/packages/agent-core/src/session/rpc.ts
@@ -1,7 +1,10 @@
import { ErrorCodes, KimiError } from '#/errors';
import type {
ActivateSkillPayload,
+ AddAdditionalDirPayload,
+ AddAdditionalDirResult,
AgentAPI,
+ AppendUserMessagePayload,
BeginCompactionPayload,
CancelPayload,
CancelPlanPayload,
@@ -90,6 +93,9 @@ export class SessionAPIImpl implements PromisableMethods {
return this.session.generateAgentsMd();
}
+ addAdditionalDir(payload: AddAdditionalDirPayload): Promise {
+ return this.session.addAdditionalDir(payload.path, payload.persist);
+ }
async prompt({ agentId, ...payload }: AgentScopedPayload) {
if (agentId === 'main') {
@@ -102,6 +108,10 @@ export class SessionAPIImpl implements PromisableMethods {
return (await this.getAgent(agentId)).steer(payload);
}
+ async appendUserMessage({ agentId, ...payload }: AgentScopedPayload) {
+ return (await this.getAgent(agentId)).appendUserMessage(payload);
+ }
+
async cancel({ agentId, ...payload }: AgentScopedPayload) {
return (await this.getAgent(agentId)).cancel(payload);
}
diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts
index b47e1cd68..acc124692 100644
--- a/packages/agent-core/src/session/subagent-host.ts
+++ b/packages/agent-core/src/session/subagent-host.ts
@@ -365,6 +365,7 @@ export class SessionSubagentHost {
const context = await prepareSystemPromptContext(
this.session.systemContextKaos(child.kaos.getcwd()),
this.session.options.kimiHomeDir,
+ { additionalDirs: child.getAdditionalDirs() },
);
child.useProfile(profile, context);
child.tools.inheritUserTools(parent.tools);
diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts
index 5fc9edf88..acd80625a 100644
--- a/packages/agent-core/test/agent/config.test.ts
+++ b/packages/agent-core/test/agent/config.test.ts
@@ -82,6 +82,31 @@ describe('Agent config', () => {
await ctx.expectResumeMatches();
});
+ it('useProfile passes additionalDirsInfo to profile system prompts', async () => {
+ const ctx = testAgent();
+ ctx.configure();
+ const profile: ResolvedAgentProfile = {
+ name: 'context-profile',
+ systemPrompt: (context) =>
+ `Prompt with additional dirs: ${context.additionalDirsInfo ?? 'none'}`,
+ tools: ['Bash'],
+ };
+
+ ctx.agent.useProfile(profile, {
+ cwdListing: 'cwd listing',
+ agentsMd: 'agents md',
+ additionalDirsInfo: '### /extra\nextra-file.txt',
+ });
+
+ expect(ctx.agent.config.systemPrompt).toBe(
+ 'Prompt with additional dirs: ### /extra\nextra-file.txt',
+ );
+
+ ctx.agent.useProfile(profile);
+
+ expect(ctx.agent.config.systemPrompt).toBe('Prompt with additional dirs: none');
+ });
+
it('config.update with cwd initializes builtin tools', async () => {
const ctx = testAgent();
ctx.configure();
diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts
index 243ca7572..fb88c4eab 100644
--- a/packages/agent-core/test/agent/permission.test.ts
+++ b/packages/agent-core/test/agent/permission.test.ts
@@ -2681,6 +2681,39 @@ describe('Default git CWD Write/Edit permission', () => {
expect(stat).not.toHaveBeenCalled();
});
+ it('still requests approval for Bash when the cwd is an additionalDir', async () => {
+ const { kaos, stat } = gitKaos({
+ markerPath: '/extra/.git',
+ statModes: { '/extra': DIR_MODE },
+ });
+ const { manager, requestApproval, telemetryTrack } = makePermissionManager(
+ async () => ({ decision: 'approved' }),
+ { cwd: '/extra', kaos, additionalDirs: ['/extra'] },
+ );
+
+ await expect(
+ manager.beforeToolCall(
+ hookContext({
+ id: 'call_bash_additional_dir_cwd',
+ args: { command: 'printf from-additional-dir', timeout: 60 },
+ }),
+ ),
+ ).resolves.toBeUndefined();
+
+ expect(requestApproval).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toolName: 'Bash',
+ action: 'run command',
+ }),
+ expect.any(Object),
+ );
+ expect(telemetryTrack).not.toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({ policy_name: 'git-cwd-write-approve' }),
+ );
+ expect(stat).not.toHaveBeenCalled();
+ });
+
it('bypasses approval for Write to a relative path inside a git cwd', async () => {
const { kaos } = gitKaos();
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
@@ -2729,6 +2762,38 @@ describe('Default git CWD Write/Edit permission', () => {
);
});
+ it.each([
+ ['Write', { path: '/extra/src/a.ts', content: 'x' }],
+ ['Edit', { path: '/extra/src/a.ts', old_string: 'A', new_string: 'B' }],
+ ] as const)('approves %s on an additionalDir path in manual mode', async (toolName, args) => {
+ const { kaos } = gitKaos();
+ const { manager, requestApproval, telemetryTrack } = makePermissionManager(
+ async () => ({ decision: 'approved' }),
+ { kaos, additionalDirs: ['/extra'] },
+ );
+
+ await expect(
+ manager.beforeToolCall(
+ hookContext({
+ id: `call_${toolName.toLowerCase()}_additional_dir`,
+ toolName,
+ args,
+ }),
+ ),
+ ).resolves.toBeUndefined();
+
+ expect(requestApproval).not.toHaveBeenCalled();
+ expect(telemetryTrack).toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({
+ policy_name: 'git-cwd-write-approve',
+ tool_name: toolName,
+ permission_mode: 'manual',
+ decision: 'approve',
+ }),
+ );
+ });
+
it('still requests approval when cwd is not inside a git work tree', async () => {
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
async () => ({ decision: 'approved' }),
@@ -2803,6 +2868,28 @@ describe('Default git CWD Write/Edit permission', () => {
expect(requestApproval).toHaveBeenCalledTimes(1);
});
+ it('still requests approval for a shared-prefix path outside additionalDirs', async () => {
+ const { kaos } = gitKaos();
+ const { manager, requestApproval, telemetryTrack } = makePermissionManager(
+ async () => ({ decision: 'approved' }),
+ { kaos, additionalDirs: ['/extra'] },
+ );
+
+ await expect(
+ manager.beforeToolCall(writeHook({ path: '/extra-evil/outside.ts', content: 'x' })),
+ ).resolves.toBeUndefined();
+
+ expect(requestApproval).toHaveBeenCalledTimes(1);
+ expect(telemetryTrack).toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({ policy_name: 'fallback-ask' }),
+ );
+ expect(telemetryTrack).not.toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({ policy_name: 'git-cwd-write-approve' }),
+ );
+ });
+
it('still requests approval for a path inside the git root but outside the cwd', async () => {
const { kaos } = gitKaos({ markerPath: '/a/.git' });
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
@@ -2841,6 +2928,34 @@ describe('Default git CWD Write/Edit permission', () => {
},
);
+ it('still requests approval for a git control file inside an additionalDir', async () => {
+ const { kaos } = gitKaos();
+ const { manager, requestApproval, telemetryTrack } = makePermissionManager(
+ async () => ({ decision: 'approved' }),
+ { kaos, additionalDirs: ['/extra'] },
+ );
+
+ await expect(
+ manager.beforeToolCall(writeHook({ path: '/extra/.git/config', content: 'x' })),
+ ).resolves.toBeUndefined();
+
+ expect(requestApproval).toHaveBeenCalledTimes(1);
+ expect(telemetryTrack).toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({
+ policy_name: 'git-control-path-access-ask',
+ tool_name: 'Write',
+ permission_mode: 'manual',
+ decision: 'ask',
+ git_control_path: true,
+ }),
+ );
+ expect(telemetryTrack).not.toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({ policy_name: 'git-cwd-write-approve' }),
+ );
+ });
+
it('still requests approval for case-variant git control files', async () => {
const { kaos } = gitKaos();
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
@@ -3103,6 +3218,34 @@ describe('Default git CWD Write/Edit permission', () => {
expect(requestApproval).toHaveBeenCalledTimes(1);
});
+ it('still requests approval for a sensitive file inside an additionalDir', async () => {
+ const { kaos } = gitKaos();
+ const { manager, requestApproval, telemetryTrack } = makePermissionManager(
+ async () => ({ decision: 'approved' }),
+ { kaos, additionalDirs: ['/extra'] },
+ );
+
+ await expect(
+ manager.beforeToolCall(writeHook({ path: '/extra/.env', content: 'SECRET=1' })),
+ ).resolves.toBeUndefined();
+
+ expect(requestApproval).toHaveBeenCalledTimes(1);
+ expect(telemetryTrack).toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({
+ policy_name: 'sensitive-file-access-ask',
+ tool_name: 'Write',
+ permission_mode: 'manual',
+ decision: 'ask',
+ sensitive_path: true,
+ }),
+ );
+ expect(telemetryTrack).not.toHaveBeenCalledWith(
+ 'permission_policy_decision',
+ expect.objectContaining({ policy_name: 'git-cwd-write-approve' }),
+ );
+ });
+
it.each(['.env.local', '.aws/credentials'])(
'still requests approval for sensitive file %s',
async (path) => {
@@ -3680,6 +3823,7 @@ function makePermissionManager(
readonly planFilePath?: string | null | undefined;
readonly kaos?: Kaos;
readonly cwd?: string;
+ readonly additionalDirs?: readonly string[];
readonly agentType?: Agent['type'];
readonly hooks?: Agent['hooks'];
readonly approvalRpc?: boolean;
@@ -3699,6 +3843,7 @@ function makePermissionManager(
type: options.agentType ?? 'main',
config: { cwd: options.cwd ?? '/workspace' },
kaos: options.kaos ?? createFakeKaos(),
+ getAdditionalDirs: () => options.additionalDirs ?? [],
emitStatusUpdated: vi.fn(),
records: { logRecord: record },
replayBuilder: { push: vi.fn() },
diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts
new file mode 100644
index 000000000..529a467ca
--- /dev/null
+++ b/packages/agent-core/test/config/workspace-local.test.ts
@@ -0,0 +1,179 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'pathe';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { testKaos } from '../fixtures/test-kaos';
+import { ErrorCodes, KimiError } from '../../src/errors';
+import {
+ appendWorkspaceAdditionalDir,
+ loadWorkspaceLocalConfig,
+ normalizeAdditionalDirs,
+ readWorkspaceAdditionalDirs,
+} from '../../src/config/workspace-local';
+
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ for (const dir of tempDirs.splice(0)) {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+async function makeProject(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'kimi-workspace-local-'));
+ tempDirs.push(root);
+ await mkdir(join(root, '.git'), { recursive: true });
+ await mkdir(join(root, 'packages', 'app'), { recursive: true });
+ return root;
+}
+
+async function expectConfigInvalid(
+ promise: Promise,
+ message: string,
+): Promise {
+ await expect(promise).rejects.toBeInstanceOf(KimiError);
+ await expect(promise).rejects.toMatchObject({
+ code: ErrorCodes.CONFIG_INVALID,
+ message: expect.stringContaining(message),
+ });
+}
+
+describe('workspace local config', () => {
+ it('returns empty workspace config when local.toml is missing', async () => {
+ const root = await makeProject();
+
+ await expect(loadWorkspaceLocalConfig(testKaos, join(root, 'packages', 'app'))).resolves.toEqual({
+ projectRoot: root,
+ configPath: join(root, '.kimi-code', 'local.toml'),
+ additionalDirs: [],
+ });
+ });
+
+ it('loads additional_dir array from the project root when started nested', async () => {
+ const root = await makeProject();
+ const sharedDir = join(root, 'shared');
+ const otherDir = join(root, 'other');
+ await mkdir(sharedDir, { recursive: true });
+ await mkdir(otherDir, { recursive: true });
+ await mkdir(join(root, '.kimi-code'), { recursive: true });
+ await writeFile(
+ join(root, '.kimi-code', 'local.toml'),
+ '[workspace]\nadditional_dir = ["shared", "other"]\n',
+ 'utf-8',
+ );
+
+ await expect(readWorkspaceAdditionalDirs(testKaos, join(root, 'packages', 'app'))).resolves.toEqual({
+ projectRoot: root,
+ configPath: join(root, '.kimi-code', 'local.toml'),
+ additionalDirs: [sharedDir, otherDir],
+ });
+ });
+
+ it('rejects string additional_dir values', async () => {
+ const root = await makeProject();
+ await mkdir(join(root, 'shared'), { recursive: true });
+ await mkdir(join(root, '.kimi-code'), { recursive: true });
+ await writeFile(
+ join(root, '.kimi-code', 'local.toml'),
+ '[workspace]\nadditional_dir = "shared"\n',
+ 'utf-8',
+ );
+
+ await expectConfigInvalid(
+ loadWorkspaceLocalConfig(testKaos, join(root, 'packages', 'app')),
+ 'workspace.additional_dir must be an array of strings',
+ );
+ });
+
+ it('rejects configured additional_dir that does not exist', async () => {
+ const root = await makeProject();
+ await mkdir(join(root, '.kimi-code'), { recursive: true });
+ await writeFile(
+ join(root, '.kimi-code', 'local.toml'),
+ '[workspace]\nadditional_dir = ["missing"]\n',
+ 'utf-8',
+ );
+
+ await expectConfigInvalid(
+ readWorkspaceAdditionalDirs(testKaos, join(root, 'packages', 'app')),
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ });
+
+ it('appends multiple directories and deduplicates normalized paths', async () => {
+ const root = await makeProject();
+ const sharedDir = join(root, 'shared');
+ const otherDir = join(root, 'other');
+ await mkdir(sharedDir, { recursive: true });
+ await mkdir(otherDir, { recursive: true });
+
+ const appended = await appendWorkspaceAdditionalDir(testKaos, root, 'shared', []);
+ const configPath = join(root, '.kimi-code', 'local.toml');
+ const before = await readFile(configPath, 'utf-8');
+
+ const duplicate = await appendWorkspaceAdditionalDir(testKaos, root, './shared', []);
+ const afterDuplicate = await readFile(configPath, 'utf-8');
+ const second = await appendWorkspaceAdditionalDir(testKaos, root, 'other', duplicate.additionalDirs);
+
+ expect(duplicate).toEqual(appended);
+ expect(afterDuplicate).toBe(before);
+ expect(second.additionalDirs).toEqual([sharedDir, otherDir]);
+ });
+
+ it('uses the actual local.toml state even when current dirs are empty', async () => {
+ const root = await makeProject();
+ const sharedDir = join(root, 'shared');
+ const otherDir = join(root, 'other');
+ await mkdir(sharedDir, { recursive: true });
+ await mkdir(otherDir, { recursive: true });
+ await mkdir(join(root, '.kimi-code'), { recursive: true });
+ const configPath = join(root, '.kimi-code', 'local.toml');
+ await writeFile(configPath, '[workspace]\nadditional_dir = ["shared"]\n', 'utf-8');
+
+ const result = await appendWorkspaceAdditionalDir(testKaos, root, 'other', []);
+
+ expect(result.additionalDirs).toEqual([sharedDir, otherDir]);
+ });
+
+ it('does not rewrite local.toml when appending an existing directory', async () => {
+ const root = await makeProject();
+ const sharedDir = join(root, 'shared');
+ await mkdir(sharedDir, { recursive: true });
+ await mkdir(join(root, '.kimi-code'), { recursive: true });
+ const configPath = join(root, '.kimi-code', 'local.toml');
+ const before = '[workspace]\nadditional_dir = ["shared"]\n';
+ await writeFile(configPath, before, 'utf-8');
+
+ const result = await appendWorkspaceAdditionalDir(testKaos, root, './shared', []);
+
+ expect(result.additionalDirs).toEqual([sharedDir]);
+ await expect(readFile(configPath, 'utf-8')).resolves.toBe(before);
+ });
+
+ it('rejects missing paths when appending additional_dir', async () => {
+ const root = await makeProject();
+
+ await expectConfigInvalid(
+ appendWorkspaceAdditionalDir(testKaos, root, 'missing', []),
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ });
+
+ it('rejects non-directory paths when appending additional_dir', async () => {
+ const root = await makeProject();
+ await writeFile(join(root, 'shared'), 'not a directory', 'utf-8');
+
+ await expectConfigInvalid(
+ appendWorkspaceAdditionalDir(testKaos, root, 'shared', []),
+ 'workspace.additional_dir must exist and be a directory',
+ );
+ });
+
+ it('deduplicates normalized additional dirs while preserving order', () => {
+ expect(
+ normalizeAdditionalDirs(['shared', './shared', 'nested//dir', 'nested/dir/../final']),
+ ).toEqual(['shared', 'nested/dir', 'nested/final']);
+ });
+});
diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts
index f660e2e99..d7ca6fb06 100644
--- a/packages/agent-core/test/harness/runtime.test.ts
+++ b/packages/agent-core/test/harness/runtime.test.ts
@@ -291,6 +291,449 @@ max_context_size = 100000
expect(mainAgent?.config.modelAlias).toBe('default-mock');
});
+ it('loads project local additional dirs into the session and main agent', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["extra"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs',
+ workDir,
+ model: 'default-mock',
+ });
+ const session = core.sessions.get(created.id);
+ const mainAgent = session?.getReadyAgent('main');
+
+ expect(created.additionalDirs).toEqual([extraDir]);
+ expect(session?.getAdditionalDirs()).toEqual([extraDir]);
+ expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]);
+ expect(mainAgent?.config.systemPrompt).toContain('## Additional Directories');
+ expect(mainAgent?.config.systemPrompt).toContain(extraDir);
+ });
+
+ it('returns additionalDirs when resuming an active session', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["extra"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_active_resume',
+ workDir,
+ model: 'default-mock',
+ });
+ const resumed = await rpc.resumeSession({ sessionId: created.id });
+
+ expect(resumed.additionalDirs).toEqual([extraDir]);
+ expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([extraDir]);
+ });
+
+ it('returns additionalDirs when resuming a closed session', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["extra"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_closed_resume',
+ workDir,
+ model: 'default-mock',
+ });
+ await rpc.closeSession({ sessionId: created.id });
+
+ const resumed = await rpc.resumeSession({ sessionId: created.id });
+ const session = core.sessions.get(created.id);
+ const mainAgent = session?.getReadyAgent('main');
+
+ expect(resumed.additionalDirs).toEqual([extraDir]);
+ expect(session?.getAdditionalDirs()).toEqual([extraDir]);
+ expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]);
+ });
+
+ it('merges caller additionalDirs when resuming a closed session', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const localDir = join(workDir, 'local');
+ const callerDir = join(workDir, 'caller');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(localDir, { recursive: true });
+ await mkdir(callerDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["local"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_resume_caller',
+ workDir,
+ model: 'default-mock',
+ });
+ await rpc.closeSession({ sessionId: created.id });
+
+ const resumed = await rpc.resumeSession({
+ sessionId: created.id,
+ additionalDirs: ['caller'],
+ });
+ const session = core.sessions.get(created.id);
+ const mainAgent = session?.getReadyAgent('main');
+
+ expect(resumed.additionalDirs).toEqual([localDir, callerDir]);
+ expect(session?.getAdditionalDirs()).toEqual([localDir, callerDir]);
+ expect(mainAgent?.getAdditionalDirs()).toEqual([localDir, callerDir]);
+ });
+
+ it('deduplicates project local and caller relative additionalDirs after resolving them', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const sharedDir = join(workDir, 'shared');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(sharedDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["shared"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_dedupe',
+ workDir,
+ model: 'default-mock',
+ additionalDirs: ['shared'],
+ });
+
+ expect(created.additionalDirs).toEqual([sharedDir]);
+ expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([sharedDir]);
+ });
+
+ it('supports multiple project local and caller additionalDirs', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const localDir = join(workDir, 'shared');
+ const callerDir = join(workDir, 'other');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(localDir, { recursive: true });
+ await mkdir(callerDir, { recursive: true });
+ await mkdir(join(workDir, '.kimi-code'), { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+ await writeFile(
+ join(workDir, '.kimi-code', 'local.toml'),
+ `[workspace]\nadditional_dir = ["shared"]\n`,
+ );
+
+ const [coreRpc, sdkRpc] = createRPC();
+ void new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_multiple',
+ workDir,
+ model: 'default-mock',
+ additionalDirs: ['other'],
+ });
+
+ expect(created.additionalDirs).toEqual([localDir, callerDir]);
+ });
+
+ it('resolves caller relative additionalDirs against workDir rather than projectRoot', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const projectRoot = join(tmp, 'repo');
+ const workDir = join(projectRoot, 'apps', 'foo');
+ const sharedDir = join(workDir, 'shared');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(join(projectRoot, '.git'), { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(sharedDir, { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_additional_dirs_workdir_relative',
+ workDir,
+ model: 'default-mock',
+ additionalDirs: ['shared'],
+ });
+
+ expect(created.additionalDirs).toEqual([sharedDir]);
+ expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([sharedDir]);
+ });
+
+ it('does not record an add-dir system reminder through the session RPC', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_add_additional_dir_record',
+ workDir,
+ model: 'default-mock',
+ });
+
+ await rpc.addAdditionalDir({
+ sessionId: created.id,
+ path: 'extra',
+ persist: true,
+ });
+
+ const records = await readMainWire(created.sessionDir);
+ expect(records).not.toContainEqual(
+ expect.objectContaining({
+ type: 'context.append_message',
+ message: expect.objectContaining({
+ origin: { kind: 'injection', variant: 'add-dir' },
+ }),
+ }),
+ );
+ expect(core.sessions.get(created.id)?.getReadyAgent('main')?.getAdditionalDirs()).toEqual([
+ extraDir,
+ ]);
+ });
+
+ it('appends a user message to the main agent context', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+
+ const [coreRpc, sdkRpc] = createRPC();
+ void new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_append_user_message',
+ workDir,
+ model: 'default-mock',
+ });
+
+ await rpc.appendUserMessage({
+ sessionId: created.id,
+ agentId: 'main',
+ input: [{ type: 'text', text: 'Added workspace directory:\n extra' }],
+ });
+
+ const records = await readMainWire(created.sessionDir);
+ expect(records).toContainEqual(
+ expect.objectContaining({
+ type: 'context.append_message',
+ message: expect.objectContaining({
+ role: 'user',
+ content: [{ type: 'text', text: 'Added workspace directory:\n extra' }],
+ }),
+ }),
+ );
+ });
+
+ it('adds an additional dir through the session RPC', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_add_additional_dir',
+ workDir,
+ model: 'default-mock',
+ });
+
+ const result = await rpc.addAdditionalDir({
+ sessionId: created.id,
+ path: 'extra',
+ persist: true,
+ });
+ const localToml = await readFile(join(workDir, '.kimi-code', 'local.toml'), 'utf-8');
+ const session = core.sessions.get(created.id);
+ const mainAgent = session?.getReadyAgent('main');
+
+ expect(result).toMatchObject({
+ additionalDirs: [extraDir],
+ projectRoot: workDir,
+ configPath: join(workDir, '.kimi-code', 'local.toml'),
+ persisted: true,
+ });
+ expect(localToml).toContain('additional_dir = [');
+ expect(session?.getAdditionalDirs()).toEqual([extraDir]);
+ expect(mainAgent?.getAdditionalDirs()).toEqual([extraDir]);
+ });
+
+ it('adds a session-only additional dir without writing local.toml or reminder', async () => {
+ tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
+ const homeDir = join(tmp, 'home');
+ const workDir = join(tmp, 'work');
+ const extraDir = join(workDir, 'extra');
+ await mkdir(homeDir, { recursive: true });
+ await mkdir(workDir, { recursive: true });
+ await mkdir(extraDir, { recursive: true });
+ await writeFile(join(homeDir, 'config.toml'), baseModelConfig());
+
+ const [coreRpc, sdkRpc] = createRPC();
+ const core = new KimiCore(coreRpc, { homeDir });
+ const rpc = await sdkRpc({
+ emitEvent: vi.fn(),
+ requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })),
+ requestQuestion: vi.fn(async () => null),
+ toolCall: vi.fn(async () => ({ output: '' })),
+ });
+
+ const created = await rpc.createSession({
+ id: 'ses_runtime_add_session_only_dir',
+ workDir,
+ model: 'default-mock',
+ });
+
+ const result = await rpc.addAdditionalDir({
+ sessionId: created.id,
+ path: 'extra',
+ persist: false,
+ });
+ const records = await readMainWire(created.sessionDir);
+
+ expect(result).toMatchObject({
+ additionalDirs: [extraDir],
+ projectRoot: workDir,
+ configPath: join(workDir, '.kimi-code', 'local.toml'),
+ persisted: false,
+ });
+ expect(core.sessions.get(created.id)?.getAdditionalDirs()).toEqual([extraDir]);
+ expect(records).not.toContainEqual(
+ expect.objectContaining({
+ type: 'context.append_message',
+ message: expect.objectContaining({
+ origin: { kind: 'injection', variant: 'add-dir' },
+ }),
+ }),
+ );
+ await expect(readFile(join(workDir, '.kimi-code', 'local.toml'), 'utf-8')).rejects.toThrow();
+ });
+
it('rejects createSession when shell runtime initialization fails', async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
const homeDir = join(tmp, 'home');
@@ -436,6 +879,15 @@ base_url = "https://search.example.test/v1"
});
});
+async function readMainWire(sessionDir: string): Promise[]> {
+ const wire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8');
+ return wire
+ .trim()
+ .split('\n')
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as Record);
+}
+
function baseModelConfig(): string {
return `default_model = "default-mock"
diff --git a/packages/agent-core/test/profile/context.test.ts b/packages/agent-core/test/profile/context.test.ts
index 150f69c04..f7a6bfa5c 100644
--- a/packages/agent-core/test/profile/context.test.ts
+++ b/packages/agent-core/test/profile/context.test.ts
@@ -4,15 +4,17 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { loadAgentsMd } from '../../src/profile/context';
+import { loadAgentsMd, prepareSystemPromptContext } from '../../src/profile/context';
import { testKaos } from '../fixtures/test-kaos';
let homeDir: string;
let workDir: string;
+let extraDirs: string[];
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
+ extraDirs = [];
vi.spyOn(testKaos, 'gethome').mockReturnValue(homeDir);
vi.spyOn(testKaos, 'getcwd').mockReturnValue(workDir);
});
@@ -21,6 +23,7 @@ afterEach(async () => {
vi.restoreAllMocks();
await rm(homeDir, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
+ await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true })));
});
describe('loadAgentsMd user-level discovery', () => {
@@ -124,3 +127,54 @@ describe('loadAgentsMd truncation marker', () => {
expect(result).not.toContain(largeContent);
});
});
+
+describe('prepareSystemPromptContext additional directories', () => {
+ it('includes additional directory listings without loading their AGENTS.md', async () => {
+ const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-'));
+ extraDirs.push(brandHome);
+ const extraDir = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-'));
+ extraDirs.push(extraDir);
+
+ await writeFile(join(workDir, 'AGENTS.md'), 'repo project instructions', 'utf-8');
+ await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8');
+ await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8');
+
+ const result = await prepareSystemPromptContext(testKaos, brandHome, {
+ additionalDirs: [extraDir],
+ });
+
+ const agentsMd = result.agentsMd ?? '';
+
+ expect(result.cwdListing).toBeTypeOf('string');
+ expect(result.additionalDirsInfo).toContain(`### ${extraDir}`);
+ expect(result.additionalDirsInfo).toContain('extra-file.txt');
+ expect(agentsMd).toContain('repo project instructions');
+ expect(agentsMd).not.toContain('extra project instructions');
+ expect(agentsMd.split('