feat: add workspace add-dir support (#812)

* feat: add workspace add-dir support

Add multi-directory /add-dir management with session-only or project-remembered persistence, directory completion, confirmation UI, and runtime workspace/permission wiring.

* fix: honor --add-dir for resumed sessions

Pass CLI additional directories through shell and prompt resume paths, resolve caller-relative dirs against workDir, and add regression coverage.

* fix: keep additional dirs AGENTS.md out of default context

Load only user-level and cwd AGENTS.md by default, while preserving additional directory listings in the prompt context.

* feat: append /add-dir result as user message

Add a session appendUserMessage RPC and use it after /add-dir so the command result is recorded as a normal user message and surfaced in the transcript.

* docs: add add-dir research and follow-up todos

Document the add-dir / local-command-stdout research findings and the follow-up tasks for stdout wrapping, slash file completion, and hints.

* feat: wrap /add-dir output as local-command-stdout

Insert the /add-dir result as a user-role <local-command-stdout> record with an injection origin directly inside Session.addAdditionalDir. It enters the model context on the next turn but does not start a turn, and stays out of the live and resumed transcript; the transient status toast is kept for immediate feedback. --add-dir is unaffected since it bypasses addAdditionalDir.

Remove the now-unused appendUserMessage RPC and SDK method.

* feat: reopen /add-dir completion after accepting a directory

Generalize the slash-argument completion reopen so it fires whenever the text before the cursor ends with '/', not only when the literal '/' key is typed. After Tab-accepting a directory (or auto-applying a single-child dir), the next level's completion list reappears automatically, so repeated Tab keeps drilling down into subdirectories. '@' file mention is unaffected.

* feat: reopen file mention completion after accepting a directory

Extend the path completion reopen so it also fires for '@' file mentions. After Tab-accepting a directory in an '@' mention, the next level's completion list reappears automatically, matching the '/add-dir' continuous-Tab behavior.

* feat: show inline argument hints for slash commands

Render a dim ghost-text argument hint inside the input box after a slash command that takes arguments, replacing the popup-only hint that was easy to miss. The hint appears once the command is typed and disappears as soon as an argument is entered, and is truncated to fit the box width. Add argument hints for /compact, /swarm, /goal and /title; /add-dir already had one.

* test: remove stale additional-dirs AGENTS.md assertion

The subagent-host test still asserted that an additional directory's AGENTS.md content appears in the agent system prompt, but additional-dirs AGENTS.md has been intentionally excluded from the default context since an earlier commit (covered by context.test.ts). Drop the stale assertion.

* fix: resolve /add-dir paths against workdir and persist via kaos

Resolve user-supplied /add-dir paths against the current workdir instead of the project root, so launching from a subdirectory behaves like the CLI --add-dir flag. Also route the local.toml read/write through the kaos abstraction instead of host fs, so the remember path works for non-local sessions.

* fix: expand ~ in /add-dir paths before resolving

The /add-dir completer emits ~/... values, but the core treated ~/foo as a relative path because pathe isAbsolute('~/foo') is false, producing <workDir>/~/foo. Expand ~ and ~/ to the home directory (via kaos.gethome()) before resolving.

* chore: remove add-dir dev docs from the branch

These were working notes (research and follow-up todos) that don't belong in the PR.

* chore: clarify add-dir changeset for users

* docs: document /add-dir, --add-dir, and local.toml

* test: flush records before reading wire in add-dir runtime tests

FileSystemAgentRecordPersistence.append buffers records and flushes asynchronously, so readMainWire can read the wire before the local-command-stdout record lands. Flush the main agent's records explicitly in the two add-dir runtime tests to make them deterministic.
This commit is contained in:
liruifengv 2026-06-22 19:42:13 +08:00 committed by GitHub
parent d434d8f0d8
commit c0eeca2469
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2709 additions and 96 deletions

View file

@ -0,0 +1,11 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---
Added the ability to add extra workspace directories:
- Use the `/add-dir <path>` command to add extra working directories to the current session, or remember them for the project.
- Use `kimi --add-dir <path>` to add them on startup.
- Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Polish file mention UX.

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ coverage/
plugins/cdn/
superpowers
.worktrees/
.kimi-code/local.toml
Dockerfile
docker-compose.yml

View file

@ -73,6 +73,14 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--add-dir <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);
@ -123,6 +131,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);

View file

@ -11,6 +11,7 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
addDirs?: string[];
}
export interface ValidatedOptions {

View file

@ -237,7 +237,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,
@ -261,7 +264,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,
@ -284,7 +290,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,

View file

@ -100,6 +100,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,

View file

@ -0,0 +1,91 @@
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import type { SlashCommandHost } from './dispatch';
type AddDirChoice = 'session' | 'remember' | 'cancel';
export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
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');
}
async function handleAddDirChoice(
host: SlashCommandHost,
sessionId: string,
path: string,
choice: AddDirChoice,
): Promise<void> {
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();
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));
}
}

View file

@ -36,6 +36,7 @@ import {
} from './config';
import { handleGoalCommand } from './goal';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handleAddDirCommand } from './add-dir';
import { parseSlashInput } from './parse';
import { handlePluginsCommand } from './plugins';
import { handleProviderCommand } from './provider';
@ -59,6 +60,7 @@ import { handleWebCommand } from './web';
export { handleLoginCommand, handleLogoutCommand } from './auth';
export { handleBtwCommand } from './btw';
export { handleAddDirCommand } from './add-dir';
export {
handleAutoCommand,
handleCompactCommand,
@ -247,6 +249,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;

View file

@ -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',
@ -82,6 +173,7 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: [],
description: 'Toggle swarm mode or run one task in swarm mode',
priority: 100,
argumentHint: '[on|off] | <task>',
completeArgs: swarmArgumentCompletions,
availability: 'idle-only',
},
@ -146,6 +238,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] | <path>',
completeArgs: addDirArgumentCompletions,
},
{
name: 'experiments',
aliases: ['experimental'],
@ -172,16 +273,14 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: [],
description: 'Compact the conversation context',
priority: 80,
argumentHint: '<instruction>',
},
{
name: 'goal',
aliases: [],
description: 'Start or manage an autonomous goal',
priority: 80,
// No argumentHint: the menu description stays as short as every other
// command's. The subcommands (status/pause/resume/cancel/replace) surface in
// the argument autocomplete list once the user types `/goal ` (see
// completeArgs), so they don't need to be spelled out inline.
argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>',
completeArgs: goalArgumentCompletions,
// status / pause / cancel are always available; creation, replacement, and
// resume start (or restart) a turn and so are idle-only.
@ -209,6 +308,7 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: ['rename'],
description: 'Set or show session title',
priority: 60,
argumentHint: '<title>',
availability: 'always',
},
{

View file

@ -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) {

View file

@ -15,6 +15,7 @@ import {
import { currentTheme } from '#/tui/theme';
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
import { extractAtPrefix } from './file-mention-provider';
import { WrappingSelectList } from './wrapping-select-list';
// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
@ -143,6 +144,11 @@ export class CustomEditor extends Editor {
private consumingPaste = false;
private consumeBuffer = '';
private argumentHints: ReadonlyMap<string, string> = new Map();
setArgumentHints(hints: ReadonlyMap<string, string>): void {
this.argumentHints = hints;
}
constructor(tui: TUI) {
// paddingX: 4 reserves column 0 for the left vertical border (│),
@ -228,6 +234,13 @@ export class CustomEditor extends Editor {
}
}
}
const hint = this.computeArgumentHint();
if (hint !== undefined) {
const line = lines[firstContentIdx];
if (line !== undefined) {
lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width);
}
}
const firstContent = lines[firstContentIdx];
if (firstContent !== undefined) {
const withPrompt = injectPromptSymbol(firstContent);
@ -244,6 +257,22 @@ export class CustomEditor extends Editor {
});
}
private computeArgumentHint(): string | undefined {
const text = this.getText();
const match = /^\/(\S+)( ?)$/.exec(text);
if (match === null) return undefined;
const cmd = match[1];
const trailingSpace = match[2] ?? '';
if (cmd === undefined) return undefined;
const hint = this.argumentHints.get(cmd);
if (hint === undefined) return undefined;
const { line, col } = this.getCursor();
if (line !== 0) return undefined;
const currentLine = this.getLines()[0] ?? '';
if (col !== currentLine.length) return undefined;
return trailingSpace.length > 0 ? hint : ` ${hint}`;
}
override handleInput(data: string): void {
const normalized = normalizeCapsLockedCtrl(data);
if (isKeyRelease(normalized)) {
@ -359,6 +388,19 @@ export class CustomEditor extends Editor {
}
super.handleInput(normalized);
this.reopenPathCompletionAfterInput();
}
private reopenPathCompletionAfterInput(): void {
const { line, col } = this.getCursor();
const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? '';
if (!textBeforeCursor.endsWith('/')) return;
if (this.isShowingAutocomplete()) return;
const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' ');
const isAtMention = extractAtPrefix(textBeforeCursor) !== null;
if (!isSlashArgument && !isAtMention) return;
(this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void })
.requestAutocomplete?.({ force: true, explicitTab: false });
}
}
@ -443,6 +485,53 @@ function highlightVisibleRanges(
return out + line.slice(rawCursor);
}
// Mirrors the editor's paddingX (see constructor). The hint is spliced into
// the first content line, which starts with this many spaces of left padding.
const EDITOR_LEFT_PADDING = 4;
// pi-tui renders the end-of-input cursor as an inverse-video space.
const CURSOR_BLOCK = '\u001B[7m \u001B[0m';
/**
* Splice a dimmed argument-hint ghost string into the first content line.
*
* The hint is purely visual: it is appended after the typed command (and
* after the cursor block when one is rendered) so the cursor stays at the
* end of the real input. It consumes trailing padding space, so the line
* width is preserved; if it would overflow the box it is truncated with an
* ellipsis. Returns the line unchanged when there is no room for a hint.
*/
function injectArgumentHint(
line: string,
hint: string,
realTextLength: number,
width: number,
): string {
const cursorIdx = line.indexOf(CURSOR_BLOCK);
const cursorPresent = cursorIdx !== -1;
const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2);
// Room left in the content area after the typed text (and cursor). The hint
// must fit within this so the rendered line keeps its width.
const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0);
const trimmed = truncateHint(hint, available);
if (trimmed.length === 0) return line;
const colored = currentTheme.fg('textDim', trimmed);
const insertAt = cursorPresent
? cursorIdx + CURSOR_BLOCK.length
: mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength);
// Everything after the insertion point is trailing padding + right padding
// (plain spaces). Replace it with the hint followed by the remaining spaces
// so the visible line width is preserved.
const trailing = line.length - insertAt;
return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length));
}
function truncateHint(hint: string, maxLen: number): string {
if (maxLen <= 0) return '';
if (hint.length <= maxLen) return hint;
if (maxLen === 1) return '…';
return `${hint.slice(0, maxLen - 1)}`;
}
/**
* Overlay a terminal-style `> ` prompt symbol on the first content line.
* Column 0 is reserved for the left vertical border (overlaid later by

View file

@ -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 {
@ -166,7 +187,7 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
function extractAtPrefix(text: string): string | null {
export function extractAtPrefix(text: string): string | null {
let tokenStart = 0;
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? '')) {
@ -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 = [''];
function collectFsMentionCandidates(
workDir: string,
additionalDirs: readonly string[],
signal: AbortSignal,
): FsMentionCandidate[] {
const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>();
const roots = [
{ root: normalizePath(resolve(workDir)), isAdditionalDir: false },
...additionalDirs.map((dir) => ({
root: normalizePath(resolve(workDir, dir)),
isAdditionalDir: true,
})),
];
let scanned = 0;
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;
}
for (const { root, isAdditionalDir } of roots) {
const stack = [''];
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.
}
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;
}
result.push({ path: relativePath, isDirectory });
if (isDirectory && !isSymlink) {
stack.push(relativePath);
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.
}
}
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<AutocompleteSuggestions | null> {
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,

View file

@ -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,

View file

@ -146,6 +146,7 @@ export type {
export interface KimiTUIStartupInput {
readonly cliOptions: CLIOptions;
readonly additionalDirs?: readonly string[];
readonly tuiConfig: TuiConfig;
readonly version: string;
readonly workDir: string;
@ -157,6 +158,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'
@ -166,6 +175,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
return {
model: '',
workDir: input.workDir,
additionalDirs: [...(input.additionalDirs ?? [])],
sessionId: '',
permissionMode: startupPermission,
planMode: input.cliOptions.plan,
@ -334,8 +344,19 @@ export class KimiTUI {
slashCommands,
this.state.appState.workDir,
this.fdPath,
this.state.appState.additionalDirs,
);
this.state.editor.setAutocompleteProvider(provider);
const argumentHints = new Map<string, string>();
for (const cmd of slashCommands) {
if (cmd.argumentHint === undefined) continue;
argumentHints.set(cmd.name, cmd.argumentHint);
for (const alias of cmd.aliases ?? []) {
argumentHints.set(alias, cmd.argumentHint);
}
}
this.state.editor.setArgumentHints(argumentHints);
}
refreshSlashCommandAutocomplete(): void {
@ -555,12 +576,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) {
@ -591,13 +615,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);
@ -1041,6 +1071,9 @@ export class KimiTUI {
setAppState(patch: Partial<AppState>): 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();
@ -1050,6 +1083,7 @@ export class KimiTUI {
this.updateQueueDisplay();
this.sessionEventHandler.retryQueuedGoalPromotion();
}
if (additionalDirsChanged) this.setupAutocomplete();
this.state.ui.requestRender();
}
@ -1066,6 +1100,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
// =========================================================================
@ -1082,14 +1122,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<void> {
@ -1098,6 +1142,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<void> {
@ -1115,6 +1160,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

View file

@ -26,6 +26,7 @@ export interface BannerState {
export interface AppState {
model: string;
workDir: string;
additionalDirs: readonly string[];
sessionId: string;
permissionMode: PermissionMode;
planMode: boolean;

View file

@ -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;
@ -367,7 +378,6 @@ describe('CLI options parsing', () => {
'--print',
'--wire',
'--agent=default',
'--add-dir=/',
'--raw-model',
'--config-file=x',
'--quiet',

View file

@ -135,6 +135,7 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[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<string>();
@ -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' });

View file

@ -181,6 +181,7 @@ describe('runShell', () => {
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
addDirs: ['../shared', '/tmp/extra'],
};
await runShell(cliOptions, '1.2.3-test');
@ -220,6 +221,7 @@ describe('runShell', () => {
expect(harness).toBeTypeOf('object');
expect(startupInput).toMatchObject({
cliOptions,
additionalDirs: ['../shared', '/tmp/extra'],
tuiConfig: {
theme: 'dark',
editorCommand: null,

View file

@ -0,0 +1,190 @@
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,
})),
};
const host = {
state,
session,
skillCommandMap: new Map<string, string>(),
setAppState: vi.fn((patch: Record<string, unknown>) => 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<typeof vi.fn>;
refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>;
appendTranscriptEntry: ReturnType<typeof vi.fn>;
showError: ReturnType<typeof vi.fn>;
showStatus: ReturnType<typeof vi.fn>;
sendNormalUserInput: ReturnType<typeof vi.fn>;
mountEditorReplacement: ReturnType<typeof vi.fn>;
restoreEditor: ReturnType<typeof vi.fn>;
};
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(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',
);
});
expect(host.appendTranscriptEntry).not.toHaveBeenCalled();
});
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 });
});
await vi.waitFor(() => {
expect(host.showStatus).toHaveBeenCalledWith(
'Added workspace directory:\n ../shared\n Saved to:\n /repo/.kimi-code/local.toml',
'success',
);
});
expect(host.appendTranscriptEntry).not.toHaveBeenCalled();
});
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(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(host.sendNormalUserInput).not.toHaveBeenCalled();
});
});

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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,187 @@ 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);
});
it('reopens the next directory level after tab-accepting a directory', async () => {
const editor = makeEditor();
const provider = new FileMentionProvider(
[
{
name: 'add-dir',
description: 'Add directory',
getArgumentCompletions: (prefix) => {
if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }];
if (prefix === '/tmp/shared/') return [{ value: '/tmp/shared/child/', label: 'child/' }];
return 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.isShowingAutocomplete()).toBe(true);
editor.handleInput('\t');
await new Promise((resolve) => setTimeout(resolve, 20));
await flushAutocomplete();
expect(editor.getText()).toBe('/add-dir /tmp/shared/');
expect(editor.isShowingAutocomplete()).toBe(true);
});
});
describe('CustomEditor @ mention completion refresh', () => {
it('reopens the next directory level after tab-accepting an @ directory', async () => {
const editor = makeEditor();
const provider: AutocompleteProvider = {
getSuggestions: vi.fn(
async (
lines: string[],
cursorLine: number,
cursorCol: number,
): Promise<AutocompleteSuggestions> => {
const text = (lines[cursorLine] ?? '').slice(0, cursorCol);
if (text === '@') {
return { items: [{ value: '@shared/', label: 'shared/' }], prefix: '@' };
}
if (text === '@shared/') {
return { items: [{ value: '@shared/child/', label: 'child/' }], prefix: '@shared/' };
}
return { items: [], prefix: '' };
},
),
applyCompletion: vi.fn(
(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
) => {
const line = lines[cursorLine] ?? '';
const beforePrefix = line.slice(0, cursorCol - prefix.length);
const afterCursor = line.slice(cursorCol);
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length,
};
},
),
};
editor.setAutocompleteProvider(provider);
editor.handleInput('@');
await new Promise((resolve) => setTimeout(resolve, 30));
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
editor.handleInput('\t');
await new Promise((resolve) => setTimeout(resolve, 30));
await flushAutocomplete();
expect(editor.getText()).toBe('@shared/');
expect(editor.isShowingAutocomplete()).toBe(true);
});
});
describe('CustomEditor slash argument hint', () => {
// 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('renders the argument hint after a command with a trailing space', () => {
const editor = makeEditor();
editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']]));
for (const char of '/add-dir ') {
editor.handleInput(char);
}
const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).toContain('[list] | <path>');
});
it('renders the argument hint after a command without a trailing space', () => {
const editor = makeEditor();
editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']]));
for (const char of '/add-dir') {
editor.handleInput(char);
}
const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).toContain('[list] | <path>');
});
it('hides the hint once an argument is typed', () => {
const editor = makeEditor();
editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']]));
for (const char of '/add-dir foo') {
editor.handleInput(char);
}
const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).not.toContain('[list] | <path>');
});
it('does not render a hint for an unknown command', () => {
const editor = makeEditor();
editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']]));
for (const char of '/unknown ') {
editor.handleInput(char);
}
const plain = editor.render(90).map(stripAnsi).join('\n');
expect(plain).not.toContain('[list] | <path>');
});
});
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 +313,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 +379,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 +423,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);

View file

@ -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'));

View file

@ -12,6 +12,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
return {
model: 'k2',
workDir: '/tmp/proj',
additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,

View file

@ -22,6 +22,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
return {
model: 'k2',
workDir: '/tmp',
additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,

View file

@ -13,6 +13,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
return {
model: 'k2',
workDir: '/tmp/proj',
additionalDirs: [],
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,

View file

@ -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');

View file

@ -266,6 +266,27 @@ auto_install = true
Changes apply on the next start, or immediately with `/reload-tui` (which reloads only `tui.toml`); `/reload` reloads both `config.toml` and `tui.toml`.
## Project-local configuration
In addition to the user-level files under `~/.kimi-code`, Kimi Code reads a project-local configuration file at `<project-root>/.kimi-code/local.toml`. It holds settings that are specific to one project checkout and typically should not be shared with teammates.
The file is created automatically when you add an extra workspace directory with [`/add-dir`](../reference/slash-commands.md) and choose to remember it for the project. You rarely need to edit it by hand.
### `[workspace]`
The `[workspace]` table groups project-level workspace settings:
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `additional_dir` | `array<string>` | No | Additional workspace directories, stored as absolute paths. Written automatically when you confirm "remember this directory" in `/add-dir`; read back on startup so the directories are available in every session of this project |
```toml
[workspace]
additional_dir = ["/absolute/path/to/shared"]
```
Because directories are stored as absolute paths, which are specific to your machine, we recommend adding `.kimi-code/local.toml` to your project's `.gitignore` so it is not committed.
## Next steps
- [Providers and models](./providers.md) — connection examples for each provider type (Kimi, Claude, OpenAI, Gemini)

View file

@ -24,6 +24,7 @@ All flags are optional — run `kimi` directly to enter an interactive session:
| `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions |
| `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning |
| `--skills-dir <dir>` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated |
| `--add-dir <dir>` | | Add an extra workspace directory for this session. Relative paths resolve against the current working directory. Can be repeated |
`-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output.

View file

@ -38,6 +38,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No |
| `/export-md [<path>]` | `/export` | Export the current session as a Markdown file | No |
| `/export-debug-zip` | — | Export the current session as a debug ZIP archive (same behavior as [`kimi export`](./kimi-command.md#kimi-export)) | No |
| `/add-dir [<path>]` | — | Add an extra workspace directory to the current session. Run without a path (or with `list`) to list configured directories. When adding, choose whether to remember the directory for the project in `.kimi-code/local.toml` | No |
## Modes & Run Control

View file

@ -266,6 +266,27 @@ auto_install = true
修改在下次启动时生效,或用 `/reload-tui` 立即生效(只重载 `tui.toml``/reload` 会同时重载 `config.toml``tui.toml`
## 项目级本地配置
除了 `~/.kimi-code` 下的用户级文件Kimi Code 还会读取位于 `<项目根目录>/.kimi-code/local.toml` 的项目级本地配置文件。它保存的是与某一个项目检出相关、通常不应与队友共享的设置。
该文件会在你通过 [`/add-dir`](../reference/slash-commands.md) 添加额外工作目录并选择记入项目时自动创建,通常无需手动编辑。
### `[workspace]`
`[workspace]` 表用于存放项目级的工作区设置:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `additional_dir` | `array<string>` | 否 | 额外工作目录列表,以绝对路径存储。在 `/add-dir` 中确认"记住此目录"时自动写入;启动时读回,使这些目录在该项目的每个会话中都可用 |
```toml
[workspace]
additional_dir = ["/absolute/path/to/shared"]
```
目录以绝对路径存储,与具体机器相关。因此建议把 `.kimi-code/local.toml` 加入项目的 `.gitignore`,避免被提交。
## 下一步
- [平台与模型](./providers.md) — 各供应商类型Kimi、Claude、OpenAI、Gemini的接入示例

View file

@ -24,6 +24,7 @@ kimi <subcommand> [options]
| `--auto` | | 以 auto 权限模式启动工具审批自动处理Agent 不会向用户提问 |
| `--plan` | | 以 Plan 模式启动新会话AI 会优先使用只读工具进行探索和规划 |
| `--skills-dir <dir>` | | 从指定目录加载 Skills替换自动发现的用户和项目目录。可重复传入 |
| `--add-dir <dir>` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 |
`-r` / `--resume``--session` 的隐藏别名;`--yes``--auto-approve``--yolo` 的隐藏别名,在帮助信息中不显示。

View file

@ -36,6 +36,7 @@
| `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 |
| `/export-md [<path>]` | `/export` | 将当前会话导出为 Markdown 文件 | 否 |
| `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 |
| `/add-dir [<path>]` | — | 为当前会话添加额外的工作目录。不带路径(或传入 `list`)运行时列出已配置的目录。添加时可选择是否将目录记入项目的 `.kimi-code/local.toml` | 否 |
## 模式与运行控制

View file

@ -65,6 +65,16 @@ export class ContextMemory {
});
}
appendLocalCommandStdout(content: string): void {
const text = `<local-command-stdout>\n${content.trim()}\n</local-command-stdout>`;
this.appendMessage({
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'injection', variant: 'local-command-stdout' },
});
}
popMatchedMessage(matcher: (origin: PromptOrigin | undefined) => boolean): boolean {
const lastDeferred = this.deferredMessages.at(-1);
const last = lastDeferred ?? this._history.at(-1);

View file

@ -1,5 +1,6 @@
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';
@ -80,6 +81,7 @@ export interface AgentOptions {
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
readonly experimentalFlags?: ExperimentalFlagResolver;
readonly replay?: ReplayBuilderOptions;
readonly additionalDirs?: readonly string[];
}
export class Agent {
@ -124,6 +126,8 @@ export class Agent {
readonly goal: GoalMode;
readonly replayBuilder: ReplayBuilder;
private additionalDirs: readonly string[];
constructor(options: AgentOptions) {
this.type = options.type ?? 'main';
this._kaos = options.kaos;
@ -140,6 +144,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.llmRequestLogger = new LlmRequestLogger(this.log);
this.blobStore = options.homedir
@ -182,6 +187,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) => {
const { requestLogFields, generateOptions } = splitGenerateOptions(options);
@ -238,6 +254,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);

View file

@ -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;
}

View file

@ -367,7 +367,7 @@ export class ToolManager {
const workspace = extendWorkspaceWithSkillRoots(
{
workspaceDir: cwd,
additionalDirs: [],
additionalDirs: this.agent.getAdditionalDirs(),
},
this.agent.skills?.registry.getSkillRoots() ?? [],
);

View file

@ -4,3 +4,4 @@ export * from './resolve';
export * from './schema';
export * from './toml';
export * from './env-model';
export * from './workspace-local';

View file

@ -0,0 +1,320 @@
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';
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<typeof WorkspaceLocalTomlSchema>;
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<string, unknown>;
readonly parsed: WorkspaceLocalToml;
}
export async function loadWorkspaceLocalConfig(
kaos: Kaos,
workDir: string,
): Promise<WorkspaceLocalConfig> {
const projectRoot = await findProjectRoot(kaos, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const file = await readWorkspaceLocalToml(kaos, 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<WorkspaceAdditionalDirsLoadResult> {
return loadWorkspaceLocalConfig(kaos, workDir);
}
export async function resolveWorkspaceAdditionalDirs(
kaos: Kaos,
projectRoot: string,
additionalDirs: readonly string[],
): Promise<string[]> {
return resolveAdditionalDirs(kaos, projectRoot, additionalDirs);
}
export async function appendWorkspaceAdditionalDir(
kaos: Kaos,
workDir: string,
inputPath: string,
_currentAdditionalDirs: readonly string[],
): Promise<WorkspaceAdditionalDirsLoadResult> {
const projectRoot = await findProjectRoot(kaos, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const additionalDir = await resolveAdditionalDir(kaos, workDir, inputPath);
const file = (await readWorkspaceLocalToml(kaos, 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 kaos.mkdir(dirname(configPath), { parents: true, existOk: true });
await kaos.writeText(configPath, `${stringifyToml(file.raw)}\n`);
return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] };
}
export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] {
const seen = new Set<string>();
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<string> {
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(
kaos: Kaos,
configPath: string,
): Promise<WorkspaceLocalTomlFile | undefined> {
let text: string;
try {
text = await kaos.readText(configPath);
} catch (error: unknown) {
if (isPathMissing(error)) return undefined;
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Failed to read ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (text.trim().length === 0) return { raw: {}, parsed: {} };
let raw: unknown;
try {
raw = parseToml(text);
} catch (error: unknown) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Invalid TOML in ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (!isPlainObject(raw)) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid workspace local config in ${configPath}`);
}
return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) };
}
function parseWorkspaceLocalToml(raw: Record<string, unknown>): 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<string[]> {
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(kaos, projectRoot, additionalDir);
if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
return resolvedDirs;
}
async function resolveAdditionalDir(
kaos: Kaos,
projectRoot: string,
additionalDir: string,
): Promise<string> {
const normalizedInput = normalizeAdditionalDirInput(additionalDir);
const resolvedDir = resolvePath(kaos, 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(kaos: Kaos, projectRoot: string, additionalDir: string): string {
const expanded = expandHome(kaos, additionalDir);
return isAbsolute(expanded) ? normalize(expanded) : resolve(projectRoot, expanded);
}
function expandHome(kaos: Kaos, value: string): string {
if (value === '~') return kaos.gethome();
if (value.startsWith('~/')) return join(kaos.gethome(), value.slice(2));
return value;
}
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<void> {
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<boolean> {
try {
await kaos.stat(filePath);
return true;
} catch {
return false;
}
}
function cloneRecord(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) return {};
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
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);
}

View file

@ -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<SystemPromptContext, 'cwdListing' | 'agentsMd'>;
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<PreparedSystemPromptContext> {
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<string> {
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<string> {
const discovered: AgentFile[] = [];
const seen = new Set<string>();
@ -57,16 +73,37 @@ export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise<stri
if (await collect(file)) break;
}
for (const dir of dirs) {
await collect(join(dir, '.kimi-code', 'AGENTS.md'));
for (const fileName of ['AGENTS.md', 'agents.md']) {
if (await collect(join(dir, fileName))) break;
for (const workDir of workDirs) {
const rootKaos = kaos.withCwd(workDir);
const rootWorkDir = rootKaos.getcwd();
const projectRoot = await findProjectRoot(rootKaos, rootWorkDir);
const dirs = dirsRootToLeaf(rootKaos, rootWorkDir, projectRoot);
for (const dir of dirs) {
await collect(join(dir, '.kimi-code', 'AGENTS.md'));
for (const fileName of ['AGENTS.md', 'agents.md']) {
if (await collect(join(dir, fileName))) break;
}
}
}
return renderAgentFiles(discovered);
}
async function loadAdditionalDirsInfo(
kaos: Kaos,
additionalDirs: readonly string[],
): Promise<string> {
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<string> {
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');
}

View file

@ -55,6 +55,7 @@ export interface CreateSessionPayload {
readonly permission?: PermissionMode | undefined;
readonly metadata?: JsonObject | undefined;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly additionalDirs?: readonly string[];
readonly client?: ClientTelemetryInfo | undefined;
}
@ -69,6 +70,7 @@ export interface ArchiveSessionPayload {
export interface ResumeSessionPayload {
readonly sessionId: string;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly additionalDirs?: readonly string[];
}
export interface ReloadSessionPayload {
@ -153,6 +155,7 @@ export interface SessionSummary {
readonly updatedAt: number;
readonly archived?: boolean | undefined;
readonly metadata?: JsonObject | undefined;
readonly additionalDirs?: readonly string[];
}
export interface PromptPayload {
@ -279,6 +282,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;
}
@ -372,6 +387,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<SessionAPI>;

View file

@ -16,6 +16,9 @@ import {
loadRuntimeConfigSafe,
mergeConfigPatch,
readConfigFileForUpdate,
normalizeAdditionalDirs,
readWorkspaceAdditionalDirs,
resolveWorkspaceAdditionalDirs,
resolveConfigPath,
resolveKimiHome,
writeConfigFile,
@ -48,6 +51,8 @@ import {
import type { CoreRPCClient } from './client';
import type {
ActivateSkillPayload,
AddAdditionalDirPayload,
AddAdditionalDirResult,
ArchiveSessionPayload,
BeginCompactionPayload,
CancelPayload,
@ -221,6 +226,18 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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,
@ -243,8 +260,6 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
// 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,
@ -264,6 +279,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
telemetry: sessionTelemetry,
pluginSessionStarts,
appVersion: this.appVersion,
additionalDirs,
});
try {
session.metadata = {
@ -301,7 +317,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
if (Object.keys(clientTelemetry).length > 0) {
sessionTelemetry.track('session_started', { resumed: false });
}
return result;
return withAdditionalDirs(result, session);
}
getCoreInfo(): CoreInfo {
@ -334,12 +350,24 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
overrides: { kaos?: Kaos; persistenceKaos?: Kaos },
): Promise<ResumeSessionResult> {
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();
@ -352,7 +380,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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),
@ -374,6 +402,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
initializeMainAgent: false,
pluginSessionStarts,
appVersion: this.appVersion,
additionalDirs,
});
let warning: string | undefined;
try {
@ -701,6 +730,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).generateAgentsMd(payload);
}
addAdditionalDir({
sessionId,
...payload
}: SessionScopedPayload<AddAdditionalDirPayload>): Promise<AddAdditionalDirResult> {
return this.requireSession(sessionId).addAdditionalDir(payload.path, payload.persist);
}
startBtw({ sessionId, ...payload }: SessionAgentPayload<EmptyPayload>): Promise<string> {
return this.sessionApi(sessionId).startBtw(payload);
}
@ -894,14 +930,18 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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 {
@ -1044,6 +1084,16 @@ function createSessionId(): string {
return `session_${randomUUID()}`;
}
function withAdditionalDirs<T>(
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;
@ -1097,12 +1147,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(

View file

@ -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<void>;
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,51 @@ export class Session {
this.refreshAgentBuiltinTools();
}
getAdditionalDirs(): readonly string[] {
return this.additionalDirs;
}
async setAdditionalDirs(additionalDirs: readonly string[]): Promise<void> {
this.additionalDirs = normalizeAdditionalDirs(additionalDirs);
for (const agent of this.readyAgents()) {
agent.setAdditionalDirs(this.additionalDirs);
}
}
async addAdditionalDir(
path: string,
persist = true,
): Promise<WorkspaceAdditionalDirsLoadResult & { readonly persisted: boolean }> {
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);
this.notifyAdditionalDirAdded(path, true, result.configPath);
return { ...result, additionalDirs, persisted: true };
}
const workspace = await readWorkspaceAdditionalDirs(systemKaos, cwd);
const additionalDirs = await resolveWorkspaceAdditionalDirs(systemKaos, cwd, [path]);
const nextAdditionalDirs = normalizeAdditionalDirs([...this.additionalDirs, ...additionalDirs]);
await this.setAdditionalDirs(nextAdditionalDirs);
this.notifyAdditionalDirAdded(path, false, workspace.configPath);
return {
projectRoot: workspace.projectRoot,
configPath: workspace.configPath,
additionalDirs: nextAdditionalDirs,
persisted: false,
};
}
private notifyAdditionalDirAdded(path: string, persisted: boolean, configPath: string): void {
const message = persisted
? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
: `Added workspace directory:\n ${path}\n For this session only`;
this.requireMainAgent().context.appendLocalCommandStdout(message);
}
/**
* Kaos used by session-internal bootstrap (AGENTS.md context, cwd listing)
* and metadata persistence. Always backed by the persistence sink (typically
@ -407,6 +464,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 +639,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,
});
}

View file

@ -1,6 +1,8 @@
import { ErrorCodes, KimiError } from '#/errors';
import type {
ActivateSkillPayload,
AddAdditionalDirPayload,
AddAdditionalDirResult,
AgentAPI,
BeginCompactionPayload,
CancelPayload,
@ -91,6 +93,9 @@ export class SessionAPIImpl implements PromisableMethods<SessionAPI> {
return this.session.generateAgentsMd();
}
addAdditionalDir(payload: AddAdditionalDirPayload): Promise<AddAdditionalDirResult> {
return this.session.addAdditionalDir(payload.path, payload.persist);
}
async prompt({ agentId, ...payload }: AgentScopedPayload<PromptPayload>) {
if (agentId === 'main') {

View file

@ -372,6 +372,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);

View file

@ -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();

View file

@ -2682,6 +2682,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(
@ -2730,6 +2763,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' }),
@ -2804,6 +2869,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(
@ -2842,6 +2929,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(
@ -3104,6 +3219,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) => {
@ -3681,6 +3824,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;
@ -3700,6 +3844,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() },

View file

@ -0,0 +1,204 @@
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<string> {
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<unknown>,
message: string,
): Promise<void> {
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('resolves an appended relative path against workDir, not the project root', async () => {
const root = await makeProject();
const appDir = join(root, 'packages', 'app');
const sharedDir = join(root, 'packages', 'shared');
await mkdir(sharedDir, { recursive: true });
const result = await appendWorkspaceAdditionalDir(testKaos, appDir, '../shared', []);
expect(result.additionalDirs).toEqual([sharedDir]);
});
it('expands a ~/ path to the home directory when appending', async () => {
const root = await makeProject();
const homeDir = testKaos.gethome();
const homeProjectDir = await mkdtemp(join(homeDir, 'kimi-workspace-local-home-'));
tempDirs.push(homeProjectDir);
const sharedDir = join(homeProjectDir, 'shared');
await mkdir(sharedDir, { recursive: true });
const tildePath = `~/${sharedDir.slice(homeDir.length + 1)}`;
const result = await appendWorkspaceAdditionalDir(testKaos, root, tildePath, []);
expect(result.additionalDirs).toEqual([sharedDir]);
});
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']);
});
});

View file

@ -291,6 +291,424 @@ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
void new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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('records a local-command-stdout message when adding a remembered dir', 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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,
});
await core.sessions.get(created.id)?.getReadyAgent('main')?.records.flush();
const records = await readMainWire(created.sessionDir);
expect(records).toContainEqual(
expect.objectContaining({
type: 'context.append_message',
message: expect.objectContaining({
role: 'user',
content: [
{
type: 'text',
text: `<local-command-stdout>\nAdded workspace directory:\n extra\n Saved to:\n ${join(workDir, '.kimi-code', 'local.toml')}\n</local-command-stdout>`,
},
],
origin: { kind: 'injection', variant: 'local-command-stdout' },
}),
}),
);
expect(core.sessions.get(created.id)?.getReadyAgent('main')?.getAdditionalDirs()).toEqual([
extraDir,
]);
});
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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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', 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<CoreAPI, SDKAPI>();
const core = new KimiCore(coreRpc, { homeDir });
const rpc = await sdkRpc({
emitEvent: vi.fn(),
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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,
});
await core.sessions.get(created.id)?.getReadyAgent('main')?.records.flush();
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).toContainEqual(
expect.objectContaining({
type: 'context.append_message',
message: expect.objectContaining({
role: 'user',
content: [
{
type: 'text',
text: '<local-command-stdout>\nAdded workspace directory:\n extra\n For this session only\n</local-command-stdout>',
},
],
origin: { kind: 'injection', variant: 'local-command-stdout' },
}),
}),
);
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 +854,15 @@ base_url = "https://search.example.test/v1"
});
});
async function readMainWire(sessionDir: string): Promise<readonly Record<string, unknown>[]> {
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<string, unknown>);
}
function baseModelConfig(): string {
return `default_model = "default-mock"

View file

@ -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('<!-- From:').length - 1).toBe(1);
});
it('loads user-level AGENTS.md once and skips additional directory AGENTS.md', async () => {
const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-'));
extraDirs.push(brandHome);
const extraDirA = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-a-'));
const extraDirB = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-b-'));
extraDirs.push(extraDirA, extraDirB);
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'shared user instructions', 'utf-8');
await writeFile(join(extraDirA, 'AGENTS.md'), 'extra A instructions', 'utf-8');
await writeFile(join(extraDirB, 'AGENTS.md'), 'extra B instructions', 'utf-8');
const result = await prepareSystemPromptContext(testKaos, brandHome, {
additionalDirs: [extraDirA, extraDirB],
});
const agentsMd = result.agentsMd ?? '';
expect(result.additionalDirsInfo).toContain(`### ${extraDirA}`);
expect(result.additionalDirsInfo).toContain(`### ${extraDirB}`);
expect(agentsMd.split('shared user instructions').length - 1).toBe(1);
expect(agentsMd).not.toContain('extra A instructions');
expect(agentsMd).not.toContain('extra B instructions');
});
});

View file

@ -1417,6 +1417,53 @@ describe('Session.createAgent', () => {
expect(child.agent.config.systemPrompt).not.toContain(`cwd=${sessionWorkDir}`);
});
it('passes session additional dirs to main and child agents', async () => {
const extraDir = '/extra/work';
const directories = new Set(['/workspace', extraDir]);
const files = new Map([
[join(extraDir, 'AGENTS.md'), 'extra agents instructions'],
[join(extraDir, 'extra-file.ts'), 'export const extra = 1;'],
]);
const session = new Session({
id: 'test-subagent-additional-dirs',
kaos: createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(0),
stat: vi.fn(async (path: string) => {
if (directories.has(path)) return stat('dir');
if (files.has(path)) return stat('file');
throw new Error(`ENOENT ${path}`);
}),
iterdir: async function* (path: string) {
if (path === extraDir) {
yield join(extraDir, 'AGENTS.md');
yield join(extraDir, 'extra-file.ts');
}
},
readText: vi.fn(async (path: string) => {
const content = files.get(path);
if (content === undefined) throw new Error(`ENOENT ${path}`);
return content;
}),
}),
homedir: '/tmp/kimi-session',
rpc: createSessionRpc(),
initializeMainAgent: false,
additionalDirs: [extraDir],
});
const main = await session.createMain();
const child = await session.createAgent(
{ type: 'sub' },
{ profile: contextProfile(), parentAgentId: 'main' },
);
expect(main.getAdditionalDirs()).toEqual([extraDir]);
expect(child.agent.getAdditionalDirs()).toEqual([extraDir]);
expect(child.agent.config.systemPrompt).toContain(`additional=### ${extraDir}`);
expect(child.agent.config.systemPrompt).toContain('extra-file.ts');
});
it('allocates the next unused generated agent id', async () => {
const session = new Session({
id: 'test-subagent-agent-id',
@ -1525,6 +1572,7 @@ function contextProfile(): ResolvedAgentProfile {
`cwd=${context.cwd}`,
`listing=${context.cwdListing ?? ''}`,
`agents=${context.agentsMd ?? ''}`,
`additional=${context.additionalDirsInfo ?? ''}`,
].join('\n'),
tools: [],
};

View file

@ -264,6 +264,26 @@ describe('path access policy', () => {
expect(result).toEqual({ path: '/extra/lib/file.py', outsideWorkspace: false });
});
it('treats additionalDir descendants as inside the workspace', () => {
expect(isWithinWorkspace('/extra/src/nested/file.ts', WORKSPACE)).toBe(true);
const result = resolvePathAccess('/extra/src/nested/file.ts', '/workspace', WORKSPACE, {
operation: 'read',
policy: DEFAULT_WORKSPACE_ACCESS_POLICY,
});
expect(result).toEqual({ path: '/extra/src/nested/file.ts', outsideWorkspace: false });
});
it('does not treat shared-prefix directories as additionalDir descendants', () => {
expect(isWithinWorkspace('/extra-evil/file.ts', WORKSPACE)).toBe(false);
const result = resolvePathAccess('/extra-evil/file.ts', '/workspace', WORKSPACE, {
operation: 'read',
policy: DEFAULT_WORKSPACE_ACCESS_POLICY,
});
expect(result).toEqual({ path: '/extra-evil/file.ts', outsideWorkspace: true });
});
it('treats multiple additionalDir entries as a union', () => {
const multi: WorkspaceConfig = {
workspaceDir: '/workspace',

View file

@ -21,6 +21,8 @@ import type { Kaos } from '@moonshot-ai/kaos';
import type { ApprovalHandler, QuestionHandler } from '#/events';
import type {
AddAdditionalDirInput,
AddAdditionalDirResult,
BackgroundTaskInfo,
ConfigDiagnostics,
CreateSessionOptions,
@ -243,6 +245,11 @@ export abstract class SDKRpcClientBase {
return rpc.generateAgentsMd({ sessionId: input.sessionId });
}
async addAdditionalDir(input: AddAdditionalDirInput): Promise<AddAdditionalDirResult> {
const rpc = await this.getRpc();
return rpc.addAdditionalDir({ sessionId: input.id, path: input.path, persist: input.persist });
}
async startBtw(input: SessionIdRpcInput): Promise<string> {
const agentId = this.interactiveAgentId;
const rpc = await this.getRpc();

View file

@ -9,6 +9,8 @@ import {
import { type ApprovalHandler, type Event, type QuestionHandler } from '#/events';
import type { SDKRpcClientBase } from '#/rpc';
import type {
AddAdditionalDirOptions,
AddAdditionalDirResult,
BackgroundTaskInfo,
CompactOptions,
CreateGoalInput,
@ -122,6 +124,25 @@ export class Session {
await this.rpc.generateAgentsMd({ sessionId: this.id });
}
async addAdditionalDir(
path: string,
options?: AddAdditionalDirOptions,
): Promise<AddAdditionalDirResult> {
this.ensureOpen();
const normalized = normalizeRequiredString(
path,
'Additional directory cannot be empty',
ErrorCodes.REQUEST_INVALID,
);
const result = await this.rpc.addAdditionalDir({
id: this.id,
path: normalized,
persist: options?.persist ?? true,
});
this.summary = { ...this.requireSummary(), additionalDirs: result.additionalDirs };
return result;
}
async startBtw(): Promise<string> {
this.ensureOpen();
return this.rpc.startBtw({ sessionId: this.id });
@ -450,6 +471,13 @@ export class Session {
throw new KimiError(ErrorCodes.SESSION_CLOSED, 'Session is closed');
}
}
private requireSummary(): SessionSummary {
if (this.summary === undefined) {
throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, 'Session summary is unavailable');
}
return this.summary;
}
}
function normalizePromptInput(input: string | PromptInput): PromptInput {

View file

@ -101,6 +101,7 @@ export interface CreateSessionOptions {
readonly metadata?: JsonObject | undefined;
readonly kaos?: Kaos | undefined;
readonly persistenceKaos?: Kaos | undefined;
readonly additionalDirs?: readonly string[];
readonly sessionStartedProperties?: TelemetryProperties;
}
@ -113,9 +114,20 @@ export interface ResumeSessionInput {
readonly id: string;
readonly kaos?: Kaos | undefined;
readonly persistenceKaos?: Kaos | undefined;
readonly additionalDirs?: readonly string[];
readonly sessionStartedProperties?: TelemetryProperties;
}
export interface AddAdditionalDirInput {
readonly id: string;
readonly path: string;
readonly persist: boolean;
}
export interface AddAdditionalDirOptions {
readonly persist: boolean;
}
export interface ForkSessionInput {
readonly id: string;
readonly forkId?: string;
@ -197,6 +209,14 @@ export interface SessionSummary {
readonly updatedAt: number;
readonly archived?: boolean | undefined;
readonly metadata?: JsonObject | undefined;
readonly additionalDirs?: readonly string[];
}
export interface AddAdditionalDirResult {
readonly additionalDirs: readonly string[];
readonly projectRoot: string;
readonly configPath: string;
readonly persisted: boolean;
}
export type ResumedSessionState = Pick<ResumeSessionResult, 'sessionMetadata' | 'agents' | 'warning'>;