diff --git a/.changeset/add-dir-workspace.md b/.changeset/add-dir-workspace.md new file mode 100644 index 000000000..be13b6f6e --- /dev/null +++ b/.changeset/add-dir-workspace.md @@ -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 ` command to add extra working directories to the current session, or remember them for the project. +- Use `kimi --add-dir ` to add them on startup. +- Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`. diff --git a/.changeset/path-completion-reopen.md b/.changeset/path-completion-reopen.md new file mode 100644 index 000000000..dbf0b51a3 --- /dev/null +++ b/.changeset/path-completion-reopen.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Polish file mention UX. diff --git a/.gitignore b/.gitignore index 602aa5038..8d331c7ca 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage/ plugins/cdn/ superpowers .worktrees/ +.kimi-code/local.toml Dockerfile docker-compose.yml diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index d55ca3675..3614eb217 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -73,6 +73,14 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--add-dir ', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); @@ -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); diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..2e0c33a42 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -11,6 +11,7 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + addDirs?: string[]; } export interface ValidatedOptions { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 3a6791bbf..0823c5548 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index b1a1afcdd..bd35ddb9d 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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, diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts new file mode 100644 index 000000000..90636cea5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -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 { + 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 { + 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)); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7ba74cecb..02226507e 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index f6274f79a..966e5ceb7 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,3 +1,7 @@ +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + import type { AutocompleteItem } from '@earendil-works/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; @@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'off', description: 'Turn swarm mode off' }, ]; +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, +]; + /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); @@ -41,6 +49,89 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; +} + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); +} + +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); +} + +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', @@ -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] | ', 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] | ', + completeArgs: addDirArgumentCompletions, + }, { name: 'experiments', aliases: ['experimental'], @@ -172,16 +273,14 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Compact the conversation context', priority: 80, + argumentHint: '', }, { 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] | ', 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: '', availability: 'always', }, { diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index c03444f9b..24098cd59 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -129,15 +129,22 @@ export class ChoicePickerComponent extends Container implements Focusable { const titleSuffix = searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, - this.opts.formatHint === undefined - ? currentTheme.fg('textMuted', ` ${hint}`) - : this.opts.formatHint(` ${hint}`), ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } if (this.opts.notice !== undefined) { - lines.push(currentTheme.fg('success', ` ${this.opts.notice}`)); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + lines.push(currentTheme.fg('success', ` ${noticeLine}`)); + } } lines.push(''); if (searchable && view.query.length > 0) { diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index ded2af648..88a527862 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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 diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index fc9dc43be..fb6ae3acb 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -1,5 +1,5 @@ import { readdirSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, @@ -20,6 +20,7 @@ export interface SlashAutocompleteCommand extends SlashCommand { interface FsMentionCandidate { readonly path: string; + readonly absolutePath: string; readonly isDirectory: boolean; } @@ -27,19 +28,24 @@ interface FsMentionCandidate { * Kimi wrapper around pi-tui's combined autocomplete provider. * * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available. While managed fd is downloading (or when it is unavailable), a - * small filesystem fallback keeps basic `@` file and folder completion usable. - * Ordinary path completion is still handled by pi-tui's readdir-backed path - * completer. This wrapper also keeps Kimi-specific slash-command guards. + * available and only the current working directory is involved. While managed fd + * is downloading, when it is unavailable, or when the session has additional + * roots, a small filesystem fallback keeps `@` file and folder completion usable + * across every root. Ordinary path completion is still handled by pi-tui's + * readdir-backed path completer. This wrapper also keeps Kimi-specific + * slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], ) { + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that // inner's argument completion can find commands by alias too. const expanded: SlashAutocompleteCommand[] = []; @@ -77,14 +83,24 @@ export class FileMentionProvider implements AutocompleteProvider { const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { - if (this.fdPath === null) { - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + if (this.fdPath === null || this.additionalDirs.length > 0) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { // If fd fails to spawn unexpectedly, keep @ completion usable. - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } } @@ -148,6 +164,11 @@ export class FileMentionProvider implements AutocompleteProvider { } } + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; + } + try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { @@ -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, diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index b0d1cc22d..03199b65a 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,4 @@ -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; @@ -11,6 +11,10 @@ import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + export interface AuthFlowHost { state: TUIState; session: Session | undefined; @@ -67,7 +71,7 @@ export class AuthFlowController { return; } - const session = await host.harness.createSession({ + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, thinking: level, @@ -77,7 +81,11 @@ export class AuthFlowController { ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, - }); + }; + if (host.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...host.state.appState.additionalDirs]; + } + const session = await host.harness.createSession(options); await host.setSession(session); host.setAppState({ sessionId: session.id, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b0a2094de..f5aa62d31 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..b8249e92c 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -26,6 +26,7 @@ export interface BannerState { export interface AppState { model: string; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index a021d2bbe..ad46d5dde 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,6 +41,7 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); @@ -307,6 +308,16 @@ describe('CLI options parsing', () => { }); }); + describe('--add-dir', () => { + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); + }); + }); + describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { let upgradeCalls = 0; @@ -367,7 +378,6 @@ describe('CLI options parsing', () => { '--print', '--wire', '--agent=default', - '--add-dir=/', '--raw-model', '--config-file=x', '--quiet', diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 101361e5a..0d1fc3ea1 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -135,6 +135,7 @@ function opts(overrides: Partial<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' }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index e93fd5db1..cf273a89d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -181,6 +181,7 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -220,6 +221,7 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], tuiConfig: { theme: 'dark', editorCommand: null, diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts new file mode 100644 index 000000000..0c3381a87 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -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(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..bc4c5894f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -3,6 +3,7 @@ import { findBuiltInSlashCommand, parseSlashInput, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, swarmArgumentCompletions, type KimiSlashCommand, @@ -73,6 +74,27 @@ describe('built-in slash command registry', () => { expect(values('Ship feature X')).toBeNull(); }); + it('offers add-dir list and directory argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = addDirArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', @@ -126,6 +148,7 @@ describe('built-in slash command registry', () => { expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ + 'add-dir', 'compact', 'btw', 'editor', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index e25a1b984..10569be53 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -39,6 +39,11 @@ describe('resolveSlashCommandInput', () => { name: 'title', args: 'New title', }); + expect(resolve('/add-dir list')).toMatchObject({ + kind: 'builtin', + name: 'add-dir', + args: 'list', + }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); expect(resolve('/btw')).toMatchObject({ kind: 'builtin', @@ -88,6 +93,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); expect(resolve('/experiments', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'experiments', @@ -121,6 +131,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); expect(resolve('/experiments', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'experiments', diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ab0878d6b..4045e3a06 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -34,6 +34,7 @@ function setDanceView(colored: boolean, phase: number): void { const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index cc3a2ff21..a910c2481 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -12,6 +12,7 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, model: 'kimi-k2', diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index fa30293cc..5dce12c26 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -7,6 +7,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { @@ -73,8 +74,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); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b898ec89c..ab0588632 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -49,17 +49,43 @@ const HELP_FULL_COMMAND = { description: 'Show help', }; +const ADD_DIR_COMMAND = { + name: 'add-dir', + description: 'Add or list an additional workspace directory', + getArgumentCompletions: (prefix: string) => + prefix === '/' + ? [ + { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, + ] + : null, +}; + describe('FileMentionProvider', () => { let workDir: string; + let extraDirs: string[]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-')); + extraDirs = []; }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); + for (const extraDir of extraDirs) { + rmSync(extraDir, { recursive: true, force: true }); + } }); + function createExtraDir(): string { + const extraDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-extra-')); + extraDirs.push(extraDir); + return extraDir; + } + it('returns null when there is no completable prefix', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); @@ -82,6 +108,20 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(completedLine).toBe('/add-dir '); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + it('searches slash command aliases and displays aliases in the command label', async () => { const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); const line = '/clear'; @@ -229,6 +269,54 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); + + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts')}`, + ); + }); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts'), + ); + expect(overlapItems).toHaveLength(1); + }); + it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { writeFileSync(join(workDir, 'README.md'), 'readme'); const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index a9cc544fe..62f75a8d0 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -12,6 +12,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index fb1cfd5fe..f8255cddf 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -22,6 +22,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index d04c9c279..068761f7c 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -13,6 +13,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 8be57e91c..a67715f2b 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -8,6 +8,7 @@ function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/kimi-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, @@ -61,6 +62,7 @@ describe('createTUIState', () => { // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); expect(state.appState.model).toBe('test-model'); + expect(state.appState.additionalDirs).toEqual([]); expect(state.appState.sessionId).toBe('sess-1'); expect(state.startupState).toBe('pending'); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0d64f5044..a099b1dc1 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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) diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 57adc417a..cc38781b7 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -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. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index cbb99390a..d95b63dda 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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 diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e0a215f56..f9b84c58c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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)的接入示例 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index f24daaf77..5cec0fa35 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -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` 的隐藏别名,在帮助信息中不显示。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 48d5c8f5f..d92d903b7 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -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` | 否 | ## 模式与运行控制 diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 7701dc0cb..fc7948953 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -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); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index b7c386b3c..ad279b6e2 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -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); diff --git a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts index 14c72f351..8cb6fffee 100644 --- a/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts +++ b/packages/agent-core/src/agent/permission/policies/git-cwd-write-approve.ts @@ -1,5 +1,5 @@ import type { Agent } from '../..'; -import { isWithinDirectory } from '../../../tools/policies/path-access'; +import { isWithinWorkspace } from '../../../tools/policies/path-access'; import { findGitWorkTreeMarker } from '../../../tools/support/git-worktree'; import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types'; import { writeFileAccesses } from './file-access-ask'; @@ -19,7 +19,15 @@ export class GitCwdWriteApprovePermissionPolicy implements PermissionPolicy { const writeAccesses = writeFileAccesses(context); if (writeAccesses.length === 0) return; - if (!writeAccesses.every((access) => isWithinDirectory(access.path, cwd, 'posix'))) { + if ( + !writeAccesses.every((access) => + isWithinWorkspace( + access.path, + { workspaceDir: cwd, additionalDirs: this.agent.getAdditionalDirs() }, + 'posix', + ), + ) + ) { return; } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 69c150023..064e7590c 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -367,7 +367,7 @@ export class ToolManager { const workspace = extendWorkspaceWithSkillRoots( { workspaceDir: cwd, - additionalDirs: [], + additionalDirs: this.agent.getAdditionalDirs(), }, this.agent.skills?.registry.getSkillRoots() ?? [], ); diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index 41e5ca174..ac97d1221 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -4,3 +4,4 @@ export * from './resolve'; export * from './schema'; export * from './toml'; export * from './env-model'; +export * from './workspace-local'; diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts new file mode 100644 index 000000000..337ae76a0 --- /dev/null +++ b/packages/agent-core/src/config/workspace-local.ts @@ -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); +} diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 49d8d8105..2e56f2aba 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -2,6 +2,7 @@ import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; +import { normalizeAdditionalDirs } from '../config'; import { listDirectory } from '../tools/support/list-directory'; import type { SystemPromptContext } from './types'; @@ -11,23 +12,38 @@ const AGENTS_MD_TRUNCATION_MARKER = const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick<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'); } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 6e11a10f0..2b7aaf54f 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -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>; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index c00a3c793..e9a242f03 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -16,6 +16,9 @@ import { loadRuntimeConfigSafe, mergeConfigPatch, readConfigFileForUpdate, + normalizeAdditionalDirs, + readWorkspaceAdditionalDirs, + resolveWorkspaceAdditionalDirs, resolveConfigPath, resolveKimiHome, writeConfigFile, @@ -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( diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index f3703c9ab..5ebf1eecd 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -11,7 +11,16 @@ import { proxyWithExtraPayload } from '#/rpc/types'; import { Agent, type AgentOptions, type AgentType } from '../agent'; import { HookEngine, type HookDef } from './hooks'; import type { PermissionManagerOptions, PermissionRule } from '../agent/permission'; -import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config'; +import { + appendWorkspaceAdditionalDir, + normalizeAdditionalDirs, + parseBooleanEnv, + readWorkspaceAdditionalDirs, + resolveWorkspaceAdditionalDirs, + resolveConfigValue, + type BackgroundConfig, + type WorkspaceAdditionalDirsLoadResult, +} from '../config'; import { makeErrorPayload } from '../errors'; import { McpConnectionManager, @@ -62,6 +71,7 @@ export interface SessionOptions { readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly appVersion?: string; readonly experimentalFlags?: ExperimentalFlagResolver; + readonly additionalDirs?: readonly string[]; } export interface SessionSkillConfig { @@ -147,6 +157,7 @@ export class Session { readonly experimentalFlags: ExperimentalFlagResolver; private toolKaos: Kaos; private persistenceKaos: Kaos; + private additionalDirs: readonly string[]; private agentIdCounter = 0; private readonly skillsReady: Promise<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, }); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 254893156..cddfe967b 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -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') { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index a39f25d64..5153acea5 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -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); diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index 5fc9edf88..acd80625a 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -82,6 +82,31 @@ describe('Agent config', () => { await ctx.expectResumeMatches(); }); + it('useProfile passes additionalDirsInfo to profile system prompts', async () => { + const ctx = testAgent(); + ctx.configure(); + const profile: ResolvedAgentProfile = { + name: 'context-profile', + systemPrompt: (context) => + `Prompt with additional dirs: ${context.additionalDirsInfo ?? 'none'}`, + tools: ['Bash'], + }; + + ctx.agent.useProfile(profile, { + cwdListing: 'cwd listing', + agentsMd: 'agents md', + additionalDirsInfo: '### /extra\nextra-file.txt', + }); + + expect(ctx.agent.config.systemPrompt).toBe( + 'Prompt with additional dirs: ### /extra\nextra-file.txt', + ); + + ctx.agent.useProfile(profile); + + expect(ctx.agent.config.systemPrompt).toBe('Prompt with additional dirs: none'); + }); + it('config.update with cwd initializes builtin tools', async () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index 8fdd3d2b1..8d0b27712 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -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() }, diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts new file mode 100644 index 000000000..202b59136 --- /dev/null +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -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']); + }); +}); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index f660e2e99..c1136b099 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -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" diff --git a/packages/agent-core/test/profile/context.test.ts b/packages/agent-core/test/profile/context.test.ts index 150f69c04..f7a6bfa5c 100644 --- a/packages/agent-core/test/profile/context.test.ts +++ b/packages/agent-core/test/profile/context.test.ts @@ -4,15 +4,17 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadAgentsMd } from '../../src/profile/context'; +import { loadAgentsMd, prepareSystemPromptContext } from '../../src/profile/context'; import { testKaos } from '../fixtures/test-kaos'; let homeDir: string; let workDir: string; +let extraDirs: string[]; beforeEach(async () => { homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-')); workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-')); + extraDirs = []; vi.spyOn(testKaos, 'gethome').mockReturnValue(homeDir); vi.spyOn(testKaos, 'getcwd').mockReturnValue(workDir); }); @@ -21,6 +23,7 @@ afterEach(async () => { vi.restoreAllMocks(); await rm(homeDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true }); + await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true }))); }); describe('loadAgentsMd user-level discovery', () => { @@ -124,3 +127,54 @@ describe('loadAgentsMd truncation marker', () => { expect(result).not.toContain(largeContent); }); }); + +describe('prepareSystemPromptContext additional directories', () => { + it('includes additional directory listings without loading their AGENTS.md', async () => { + const brandHome = await mkdtemp(join(tmpdir(), 'kimi-agents-empty-brand-')); + extraDirs.push(brandHome); + const extraDir = await mkdtemp(join(tmpdir(), 'kimi-agents-extra-')); + extraDirs.push(extraDir); + + await writeFile(join(workDir, 'AGENTS.md'), 'repo project instructions', 'utf-8'); + await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8'); + await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8'); + + const result = await prepareSystemPromptContext(testKaos, brandHome, { + additionalDirs: [extraDir], + }); + + const agentsMd = result.agentsMd ?? ''; + + expect(result.cwdListing).toBeTypeOf('string'); + expect(result.additionalDirsInfo).toContain(`### ${extraDir}`); + expect(result.additionalDirsInfo).toContain('extra-file.txt'); + expect(agentsMd).toContain('repo project instructions'); + expect(agentsMd).not.toContain('extra project instructions'); + expect(agentsMd.split('<!-- 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'); + }); +}); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 5354e4756..592c4a8ff 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -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: [], }; diff --git a/packages/agent-core/test/tools/path-guard.test.ts b/packages/agent-core/test/tools/path-guard.test.ts index b28be6827..44cd3b809 100644 --- a/packages/agent-core/test/tools/path-guard.test.ts +++ b/packages/agent-core/test/tools/path-guard.test.ts @@ -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', diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 9dd29822f..2b088df9e 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -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(); diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 97857bf32..899529760 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -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 { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index f1c67d4e2..ff1f0fd95 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -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'>;